Compare commits

..

149 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
Deivid Soto
e89b647dfa chore(release): 0.8.1
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.8.1
- Update CHANGELOG.md
2026-05-08 17:23:19 +02:00
Deivid Soto
26814ff6f7 feat(config): set default values for WebRTC and transcoding in minimal TOML config 2026-05-08 17:21:53 +02:00
Deivid Soto
209ea38ecf feat(transcode): dynamic H.264 level + HW probe + capability reporting
Three related fixes around 4K-source transcoding that left the web
player stuck on "preparing session" with no useful diagnostics:

1. Dynamic -level:v derived from output height (hls.go, transcoder.go).
   The previous fixed "4.0" silently rejected anything taller than 1080p
   inside libx264 — "frame MB size > level limit", "DPB size > level
   limit" — and emitted unplayable segments. Helper H264LevelForHeight()
   now picks 4.0 / 5.0 / 5.1 / 6.0 from the actual encode height.

2. New `unarr probe-hwaccel` diagnostic command. Lists the HW encoders
   compiled into ffmpeg, the device files / drivers present, and the
   backend the daemon would actually pick today. Surfaces the canonical
   gotcha: a host with an RTX 3090 + nvidia-smi but a Homebrew ffmpeg
   built without --enable-nvenc still falls back to libx264 software.

3. Register payload now includes hwAccel + maxTranscodeHeight so the web
   side can suggest a smaller alternate quality before the user even
   tries to play a 4K source on a software-only host. Software-only =
   1080p cap, any HW backend = 2160p cap.
2026-05-08 15:57:02 +02:00
Deivid Soto
01941ed2e4 fix(streaming): allow HLS sessions when webrtc disabled
OnWebRTCSession gated cfg.Download.WebRTC.Enabled before the
transport=="hls" branch, so HLS sessions were rejected even though
they only need ffmpeg + StreamServer (no WebRTC peer).

Reorder: path validation first, then HLS branch, then WebRTC.Enabled
gate (only for DataChannel transport). HLS now works without enabling
[downloads.webrtc].
2026-05-08 12:44:06 +02:00
Deivid Soto
6ce743c39d fix(self-update): auto-restart live daemon after upgrade
Old isRunningAsDaemon() only matched "start" in argv — never true for
`unarr self-update`, so the daemon kept running the old binary in memory
and heartbeat reported the stale version (web gated features wrong).

Now: detect live daemon via state file + isDaemonAlive (PID alive +
heartbeat fresh), call runDaemonSvcRestart through the system service
manager. On failure show clear manual recovery command instead of
leaving the daemon dead. No-op when daemon is not running.
2026-05-08 12:43:59 +02:00
Deivid Soto
75df0e4308 refactor(streaming): improve signal handling and remove unused components 2026-05-08 12:39:07 +02:00
Deivid Soto
c5d4c4f3e3 chore(gitignore): add dist-ffbinaries to ignored files 2026-05-08 11:29:54 +02:00
Deivid Soto
36bd9edbeb chore(release): 0.8.0
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.8.0
- Update CHANGELOG.md
2026-05-08 11:27:47 +02:00
Deivid Soto
4ed95f5f4c chore(streaming): post-review fixes — race lock, dead branch, stderr cap
Follow-ups from /critico review on commits eb2548f + 40e7977. No
functional change.

- engine/hls.go restartFromSegment now reads `s.exited` under
  `readyMu`. The field is documented as readyMu-protected (see field
  declaration) and writers in waitFFmpeg / pollSegments hold the lock
  consistently; the previous direct read produced a `go test -race`
  warning under concurrent restart paths.
- engine/hls.go renderMasterPlaylist drops the `defaultIdx := -1`
  branch that was unreachable (no rendition was ever flagged DEFAULT
  or AUTOSELECT). Output is unchanged; the source is just shorter.
- engine/hls.go subtitle "(forzados)" suffix → "(forced)". Daemon
  convention is English; the web client localises if needed.
- engine/hls.go hlsStderrCapture now also caps single-write payloads
  larger than maxStderrBuf (was only capping the cumulative buffer).
- engine/hls.go waitFFmpeg restart-window reset drops the redundant
  `!IsZero` guard — a zero time is far enough in the past that the
  `> restartWindow` branch covers it.
2026-05-08 09:27:08 +02:00
Deivid Soto
40e7977cf5 fix(streaming): bounded ffmpeg auto-restart + tmpdir gc + probe/stderr safety
Reliability hardening pass for the HLS daemon. None of these change the
public API, all reduce the chances of an end-user seeing a broken
session in production.

- engine/hls.go waitFFmpeg now supervises ffmpeg: on a non-graceful
  exit while the session is still in use, restart from the last good
  segment up to 3 times within a 60 s window. Beyond that we give up
  and log the file as broken — better than a perpetually black player
  with no error.
- engine/hls.go CleanupHLSOrphanDirs() removes tmpdirs older than 1 h
  at startup; cmd/daemon.go calls it before streamSrv.Listen so a
  daemon crash + restart doesn't leak gigabytes of segment files.
- engine/hls.go StartHLSSession wraps ffprobe in a 15 s timeout. A
  hung probe on a slow remote fs would otherwise block the goroutine
  forever and the player would stay on "Preparando sesion".
- engine/hls.go hlsStderrCapture buffer is capped at 64 KiB; a
  misbehaving ffmpeg that emits megabytes without newlines used to
  grow daemon memory unbounded.
2026-05-08 08:51:19 +02:00
Deivid Soto
eb2548f9a6 feat(streaming): seek-restart, single-session, idle sweeper, probe.json
Follow-ups on the daemon HLS pipeline (0fc0e1c):

- engine/hls.go HLSSession.Register now closes every other active
  session in the registry. Modeled as "one viewer == one transcode" so
  repeated quality switches or page reloads don't leave orphan ffmpegs
  saturating the CPU until the idle sweeper reaps them 30 min later.
- engine/hls.go restartFromSegment kills + respawns ffmpeg with
  -ss / -output_ts_offset / -start_number when the browser asks for a
  segment far ahead of the writer head. Segments already on disk stay
  cached. Without this, a user dragging the scrubber to minute 30 of a
  fresh stream blocks until the encoder reaches minute 30 in real time.
- engine/hls.go subtitle disambiguation: never set DEFAULT=YES on any
  rendition (anime forced "signs only" tracks were autoselected and
  rendered nothing during opening dialogue, looking broken). Names get
  numeric suffixes when language is duplicated; FORCED tracks get a
  "(forzados)" suffix.
- engine/hls.go ProbeInfo() exposes codec / audio / subtitle metadata
  to the new GET /hls/<id>/probe.json endpoint for the player's info
  badge + bandwidth logic.
- engine/hls.go scale chain fix: chains a trunc(iw/2)*2 scale after
  the height cap so libx264 stops rejecting odd widths (853x480 etc.).
- engine/hls.go HW encoder tuning: NVENC -preset p4 -rc vbr -tune hq,
  QSV -preset medium.
- engine/stream_server.go routes /hls/<id>/probe.json to the session.
- cmd/daemon.go runs an idle sweeper goroutine every 5 min, reaping
  sessions whose last segment fetch was >30 min ago.
2026-05-07 23:55:05 +02:00
Deivid Soto
0fc0e1c21a feat(streaming): add HLS transport pipeline (daemon side)
Introduces an HLS-over-HTTP path as Plan B for in-browser streaming. The
WebRTC + MSE pipeline keeps working untouched; the new path is selected
when the backend sets transport="hls" on a streaming session.

Daemon scope:
- engine/hls.go: HLSSession + HLSSessionRegistry. Spawns ffmpeg with
  -f hls -hls_segment_type fmp4 + force_key_frames aligned with 4 s
  segments. Pre-renders master + media playlists from the probe duration
  so the browser knows the total timeline before any segment exists,
  fixing seek/duration/pause/multi-track issues seen with the live fMP4
  pipe.
- engine/probe.go: enumerate every audio + subtitle track instead of
  collapsing to a single default audio track.
- engine/stream_server.go: route /hls/<id>/{master.m3u8,video/...,
  subs/...} to the matching session. Emit a synthesised single-VTT
  subtitle playlist per text track; bitmap subs (PGS/DVB) skip silently.
- cmd/daemon.go: branch on WebRTCSession.Transport == "hls" to register
  an HLS session instead of running the legacy DataChannel pump.
- agent/types.go: WebRTCSession.Transport + AudioIndex fields.

Backend + web sides land in a follow-up commit.
2026-05-07 16:10:22 +02:00
Deivid Soto
81abc4acca fix(transcoder): force aac stereo 48khz + frag_duration for mse compat
Two transcoder fixes for browser MediaSource Extensions parsing:

1. -ar 48000 -ac 2 on the audio output. Source 5.1 / 7.1 streams produced
   a moov atom Chrome CHUNK_DEMUXER refuses to parse, even when the video
   metadata is fine and a non-MSE video element accepts the same file.
   Forcing AAC-LC stereo 48 kHz makes the moov shape MSE-compatible.

2. -frag_duration 1000000 (1 second) so each moof+mdat fragment caps at
   ~1s of media. Without it, ffmpeg only splits at keyframes and high-
   bitrate 1080p produces 8 MiB+ mdat boxes — MSE waits for the whole
   mdat before parsing the first fragment, so playback never starts.

3. -movflags +negative_cts_offsets so b-frames carry the right pts/dts
   offsets and the playhead doesn't reset every fragment.

4. New range_req debug log to make sizing bugs greppable.
2026-05-07 14:59:43 +02:00
Deivid Soto
27fe84f2a0 fix(transcoder): force main profile + setparams Rec.709 + serveRange wait
1. -profile:v main + -level:v 4.0 to avoid Chrome's HW decoder path that
   failed with "VaapiWrapper: failed initializing for h264 high" on Linux.
2. setparams to rewrite HDR HEVC color metadata to SDR Rec.709 so browsers
   don't reject wide-gamut output.
3. serveRange caps `want` by estimated final size (not current). ReadAt
   blocks until ffmpeg catches up — that's the right behaviour. Returning
   RangeEnd inmediato was making the browser abort with "Format error".
4. Debug log on every range_req.
2026-05-07 13:48:45 +02:00
Deivid Soto
457d6e1f7c fix(transcoder): correct scale filter + always force yuv420p
The previous scale expression `min(iw,iw*H/ih)':'min(ih,H)` produced odd
widths (e.g. 1425×720 for a 16:9 source capped at 720p) which libx264
refuses with `width not divisible by 2`, killing the encoder before a
single byte was written.

Switch to `scale=-2:H:force_original_aspect_ratio=decrease`, which
derives a width that preserves aspect ratio AND is rounded to a multiple
of 2. Always set `-pix_fmt yuv420p` so 10-bit HEVC sources are downcast
to the 8-bit format browser <video> elements actually decode.

Also add `-y`, guard nil pipe in Close(), and the related transcode
plumbing for browser-decided per-session quality.
2026-05-07 11:52:28 +02:00
Deivid Soto
70f7337226 feat(stream): per-session quality cap from web
Adds WebRTCSession.Quality to the sync payload so the daemon can pick a
MaxHeight + bitrate per session instead of using the global config cap.

resolveQualityCap() maps the label to a (height, b:v) pair and
buildStreamSource() promotes a passthrough decision to ActionTranscodeVideo
when the source resolution exceeds the cap (4K source on a phone client
with quality="720p" must transcode, not pass-through).

Also lands the transcode-on-by-default fix for legacy configs without a
[downloads.transcode] section so existing installs pick up h264+aac
fallback for HEVC/AC3 content without re-running setup.
2026-05-07 10:13:45 +02:00
Deivid Soto
66ac79664b feat(stream): real-time transcoding for non-browser-decodable codecs
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.

Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
  * diskFileSource: passthrough when codecs are already browser-friendly.
  * transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
    A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
    produces output. Estimate of final size (bitrate × duration) is
    announced over the wire so the browser's scrubber has something to
    anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
  carries Transcoding=true + an estimated total size; Seekable=true (we
  read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
  cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
  (NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
  (auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
  max_height (optional downscale), max_concurrent (safety cap).

Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
2026-05-07 09:26:05 +02:00
Deivid Soto
4314c06c5c feat(stream): pion-based WebRTC byte streamer for browser playback
Replaces the broken anacrolix WebTorrent path with a custom WebRTC peer
that the browser drives directly. Architecture matches plan/clever-
weaving-dove.md (Fase 2 + 3 + 6 of the streaming pivot).

- engine/wire: shared 12-byte binary frame format (Hello / RangeReq /
  RangeData / RangeEnd / Cancel / Ping / Pong / SeekHint). Roundtrip +
  oversized-frame rejection tests.
- agent/signal_client: SSE consumer + POST sender for SDP/ICE relay
  through /api/internal/stream/signal/<id>; auto-reconnects.
- engine/webrtc_stream: pion v4 PeerConnection + DataChannel pump.
  Reads file via os.ReadAt, chunks RangeData at 16 KiB, honours app-
  level backpressure with SetBufferedAmountLowThreshold.
- cmd/daemon dispatcher learns mode webrtc_stream + new
  webrtcSessionRegistry tracks per-session cancel funcs for clean
  shutdown.
- engine/probe + hwaccel + transcoder: foundation for Fase 2.5
  (codec detection, NVENC/QSV/VAAPI/VideoToolbox autodetection,
  ffmpeg pipe wrapper to fragmented MP4). Integration into
  webrtc_stream still pending.
- pion/webrtc/v4 promoted from indirect to direct dep.

End-to-end against unarr-dev confirms a 122 MB 1080p H.264 / AAC MP4
plays in Chrome with the new pipeline.
2026-05-06 23:12:38 +02:00
Deivid Soto
4c52d9b039 chore(torrent): bump anacrolix log level Critical → Warning for visibility
Surfaces tracker-announce + WebRTC peer events that were previously
swallowed by the Critical filter. Required for diagnosing the browser
↔ Go piece-transfer issue uncovered during the e2e smoke (peers
connect, signalling brokers, WebRTC handshake completes, but
anacrolix's outbound seeding to webtorrent.js browsers — known
upstream weak spot, issues #402/#752/#805 — produces zero pieces).

No behaviour change in normal operation; only changes what gets
logged.
2026-05-06 21:17:11 +02:00
Deivid Soto
e50dd17a00 feat(seed-file): unarr-side handler for browser-on-demand seeding (Fase 4.7.c)
Closes the agent half of in-browser playback for arbitrary files. When
the web app inserts a download_task with mode="seed_file", the daemon
now wraps the on-disk file as a single-file torrent, adds it to the
existing WebRTC-enabled torrent client, and reports the generated
info_hash back so the browser can target /stream/<hash>.

Pieces:

- internal/agent/types.go: Task.FilePath (received from claim) +
  StatusUpdate.InfoHash (sent back). Both serialise compatibly with
  the matching Zod schemas in the Next.js sync route.

- internal/engine/seed_file.go: SeedFile(client, filePath, trackers)
  builds the metainfo via metainfo.Info.BuildFromFilePath +
  bencode.Marshal, then AddTorrent + DownloadAll() so anacrolix
  hashes the file and flips pieces to "have" as it goes. The
  libtorrent piece-size ladder is mirrored from wstracker-probe so
  generated torrents are interoperable with mainstream clients.
  SeedFileOnDownloader is the daemon-facing convenience wrapper —
  bails loud when [downloads.webrtc].enabled = false instead of
  silently producing a torrent no browser can find.

- internal/cmd/seed_file_handler.go: handleSeedFileTask invoked from
  the existing OnTasksClaimed dispatcher in daemon.go for mode=
  seed_file. Validates filePath, calls the engine helper, and pushes
  the resulting info_hash via Client.ReportStatus. Failures (missing
  file, WebRTC disabled, ffmpeg-style oddities) report status="failed"
  + errorMessage so the browser's WatchInBrowserButton can show the
  reason instead of timing out at 60 s.

- internal/cmd/daemon.go: dispatcher learns the seed_file branch in
  the same shape as the existing stream branch.

Tests (6 unit, all green):
- SeedFile rejects missing files + directories.
- SeedFile yields a deterministic info_hash for the same payload across
  fresh clients (web client polls expecting this).
- SeedFileOnDownloader errors when WebRTC is disabled.
- chooseSeedPieceLength matches the ladder breakpoints.
- makeAnnounceList handles nil/empty/partial inputs.

Web side compatible: mode=seed_file is already accepted by the sync
schema; agent.Task.filePath + StatusUpdate.infoHash now propagate
through the existing claim/report endpoints. End-to-end browser ↔
unarr smoke is the next concrete verification step (needs a running
unarr-dev daemon plus library scan + a file with no source torrent).
2026-05-06 16:28:01 +02:00
Deivid Soto
2aeabe6b50 feat(wstracker-probe): -seed FILE mode for browser ↔ unarr e2e validation
Extends the probe binary so it can do more than verify tracker reach:
when given a real file, it builds a single-file torrent in memory,
seeds it via the WebTorrent peer wire, and prints the magnet URI
(with the WSS tracker injected). Useful for proving the end-to-end
streaming path before any actual unarr daemon work lands.

Internally uses anacrolix/torrent's metainfo.Info.BuildFromFilePath
+ bencode.Marshal to mint InfoBytes, then AddTorrent → seed loop.
Piece length picked from a libtorrent-like ladder (16 KiB → 4 MiB)
so the resulting torrent is interoperable with mainstream clients.

Validation: synthesised a 5 s 320×240 H.264+AAC mp4 with ffmpeg
(`testsrc + sine`), seeded it via this binary against the production
wss://tracker.torrentclaw.com endpoint, opened the in-browser player
at /stream/<info_hash>. Browser reported `downloaded: 105 KB / 105 KB`
and rendered a working <video> with controls. Seeder reported
`uploaded=107452 bytes`. Pieces flowed P2P over WebRTC data channel,
zero relay through TorrentClaw infrastructure.

Closes the manual half of Fase 4.4 of the streaming plan; the
companion player change (file.blob() in place of the v2-removed
file.renderTo) is committed separately on the web side.
2026-05-06 14:46:38 +02:00
Deivid Soto
c2e9925162 test(streaming): integration tests with real ffmpeg (skipped without it)
Three end-to-end checks that the transcoder actually produces playable
output, not just plausible argv. Skip cleanly on hosts without ffmpeg
on PATH so unit-test CI keeps working.

- TestTranscoder_DirectPlayProducesH264 — synth h264+aac MP4 via
  `ffmpeg -f lavfi testsrc/sine`, run Analyze (expect direct play),
  Stream to disk, ffprobe the result, assert codecs are still h264+aac.
- TestTranscoder_TranscodeHEVCToH264 — synth hevc+ac3 MKV, expect
  transcode decision, Stream to memory, ffprobe-verify the output is
  h264+aac. Skipped if libx265 isn't compiled in.
- TestTranscoder_AnalyzeReportsRealMediaInfo — sanity check that
  Analyze returns a usable mediainfo (320x240, ~2s duration) the API
  handler can show to the player.

Verified locally:
  PASS: TestTranscoder_DirectPlayProducesH264 (0.09s)
  PASS: TestTranscoder_TranscodeHEVCToH264   (0.22s)
  PASS: TestTranscoder_AnalyzeReportsRealMediaInfo (0.06s)
2026-05-06 11:35:52 +02:00
Deivid Soto
75dcc0f1cb feat(streaming): ffmpeg transcoding pipeline (direct play / fMP4 / HW accel)
The browser-side WebRTC reproductor needs MP4 / H.264 / AAC / yuv420p to
keep MSE happy. This package decides per request whether to:

  • direct-play  — input already MSE-compatible, just remux to fMP4
  • transcode    — re-encode video (libx264 / NVENC / QSV / VAAPI /
                   VideoToolbox) + audio (AAC), fragment to fMP4

Pieces:

- internal/streaming/transcoder.go — AnalyzeCompatibility decides the
  recipe from a parsed mediainfo. CompatibilityReport carries the reasons
  so the player UI can show "transcoding video: HEVC → H.264".

- internal/streaming/ffmpeg_args.go — BuildFFmpegArgs assembles the argv
  for ffmpeg. Direct play uses `-c copy`; transcode uses libx264 or the
  selected HW encoder. Output is always fragmented MP4 piped to stdout
  (-movflags frag_keyframe+empty_moov+default_base_moof) so the HTTP
  handler can stream straight to the browser without disk I/O.

  Quality ladder: 480p (1.5Mb), 720p (3.5Mb), 1080p (6Mb), 2160p (25Mb).
  Default 1080p when unset / unknown. -ss seek for resume / scrubbing.

- internal/streaming/hwaccel.go — DetectHWAccel runs `ffmpeg -encoders`
  once per process and caches the best available. Order: NVENC → QSV →
  VAAPI → VideoToolbox → libx264. VAAPI is the only family that wires up
  HW decode too (`-hwaccel vaapi`); the others software-decode and HW-
  encode (works fine and avoids /dev/dri permission rabbit holes).

- internal/streaming/stream.go — Transcoder facade wires Analyze + Stream
  together for the API handler in Fase 4. Captures the last 8 KiB of
  ffmpeg stderr for diagnosable errors without unbounded memory.

Tests (20 unit, all green):
- AnalyzeCompatibility: h264+aac direct, video-only direct, HEVC →
  transcode, 10-bit HDR → transcode, EAC3 audio → transcode, nil guards
- ResolveQuality: empty + unknown fallback to 1080p, 4-step ladder
- BuildFFmpegArgs: direct play -c copy, transcode libx264 + bitrate +
  scale, NVENC swaps encoder & drops preset, VAAPI injects -hwaccel +
  scale_vaapi, -ss timestamp formatting
- HWAccel: encoder-name table, VAAPI is the only one with HW decode
- formatDuration: zero, sub-second, HH:MM:SS, negative-clamped
- cappedBuffer: tail retention through multi-write and large-write paths
- NewTranscoder: rejects empty paths
2026-05-06 11:34:57 +02:00
Deivid Soto
e68b127acc feat(release): bundle ffmpeg + ffprobe in tarballs and Docker image
Operators no longer have to install ffmpeg manually. Both the release
tarballs (5 platforms × 2 binaries) and the Docker image now ship a
working ffmpeg + ffprobe pair adjacent to the unarr binary;
ResolveFFmpeg / ResolveFFprobe pick them up via the "adjacent to
executable" branch with zero configuration.

Tarball bundle (scripts/download-ffmpeg-static.sh + .goreleaser.yml):
- ffbinaries.com (johnvansickle / Zeranoe-style static GPL builds) for
  linux-amd64, linux-arm64, darwin-amd64, windows-amd64
- evermeet.cx universal Mach-O for darwin-arm64 (ffbinaries lacks it)
- BtbN/FFmpeg-Builds for windows-arm64 (ffbinaries lacks it)
- Idempotent fetch with curl --retry 5 so transient github.com SSL
  errors don't fail the goreleaser before-hook
- New `before.hooks` runs the script automatically per release; archive
  files glob `dist-ffbinaries/{{ .Os }}-{{ .Arch }}/*` + strip_parent
- Migrated to non-deprecated `formats: [tar.gz]` / `formats: [zip]`
- Verified via `goreleaser release --snapshot --clean --skip=publish` —
  6 archives all carry ffmpeg + ffprobe (~60-130MB each)

Docker image (Dockerfile):
- Replaced the failing BtbN static glibc binaries with Alpine's native
  musl `apk add ffmpeg`. The static GPL builds need glibc + libmvec /
  libgcc_s; gcompat alone is not enough (vector-math symbols unresolved).
  Alpine ships ffmpeg 6.1.2 which is fine for the WebRTC transcoder.
- Image size 174MB, built + ffmpeg/ffprobe/unarr smoke OK.

Targets the v0.8 unarr release (per user direction — new feature, not
a patch). dist-ffbinaries/ added to .gitignore.
2026-05-06 11:26:01 +02:00
Deivid Soto
727ab19468 feat(mediainfo): ResolveFFmpeg + DownloadFFmpeg mirroring ffprobe pattern
Adds the ffmpeg-binary half of the resolution stack so the upcoming
WebRTC streaming transcoder (Fase 3.3) has a single point of entry.

Search order matches ResolveFFprobe so operators don't need to learn a
second mental model:
  1. Explicit path  (--ffmpeg flag / library.ffmpeg_path config)
  2. FFMPEG_PATH env var
  3. "ffmpeg" on PATH (system install)
  4. Adjacent to the unarr executable (release tarball bundles it here —
     this is the preferred path; see Fase 3.2 goreleaser changes)
  5. Cache dir (sibling of the cached ffprobe binary)
  6. Auto-download from ffbinaries.com (~70MB) as last resort

Includes:
- internal/library/mediainfo/ffmpeg.go         — ResolveFFmpeg + actionable
  Docker / non-Docker error messages
- internal/library/mediainfo/ffmpeg_download.go — DownloadFFmpeg, reuses
  ffprobePlatformKey + ffprobeAPIClient + ffprobeDLClient + extractFromZip
  helpers; bumps maxZipSize to 200MB (ffmpeg static is ~70-100MB)
- internal/config: LibraryConfig.FFmpegPath toml field for explicit paths
- 4 unit tests: explicit OK, explicit missing, env var, sibling cache path

Tarball bundling and the actual transcoding pipeline land in the next
two commits.
2026-05-06 09:49:32 +02:00
Deivid Soto
aa291320f5 test(wstracker-probe): standalone Go binary to verify WSS tracker reachability
Tiny `go run ./cmd/wstracker-probe` that spins up an anacrolix/torrent
Client with WebRTC enabled, advertises a random info_hash to the given
WSS tracker, and reports via Callbacks.StatusUpdated whether the
announce round-trip succeeded.

Used as the production smoke for unarr ↔ wss://tracker.torrentclaw.com:

  $ /tmp/wstracker-probe -tracker wss://tracker.torrentclaw.com -timeout 30s
  [probe] tracker=wss://tracker.torrentclaw.com info_hash=e978df8d... timeout=30s
  [probe] tracker connected: wss://tracker.torrentclaw.com
  [probe] tracker announce OK: wss://tracker.torrentclaw.com ih=e978df8d...
  [probe] OK — tracker announce succeeded

Disables TCP/uTP/DHT/IPv6/UPnP — only the WS tracker path matters here.
Exit codes: 0 success, 1 announce error, 2 timeout.
2026-05-06 09:40:37 +02:00
Deivid Soto
f6117ddeb9 feat(torrent): act as WebTorrent peer for browser ↔ unarr P2P streaming
Wires anacrolix/torrent's built-in webtorrent package so a browser
running webtorrent.js can fetch pieces from this CLI via WebRTC data
channels. The daemon stays the seeder; we never relay bytes through
TorrentClaw infrastructure — same legal posture as today.

Changes:
- internal/config: new [downloads.webrtc] section
  (enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass).
  Disabled by default, opt-in via config.toml. When enabled but
  trackers / STUN slices are empty, defaults are reapplied on Load() so
  users get a working setup with a single `enabled = true`.
- internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers
  / ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList
  and forces NoUpload=false when WebRTC is on (browsers can't pull
  otherwise). buildMagnet now accepts variadic extra trackers and the
  downloader method prepends WSS trackers so anacrolix's
  webtorrent.TrackerClient picks them up first.
- internal/engine/webrtc.go: BuildICEServers helper converts the TOML
  WebRTCConfig into []webrtc.ICEServer with shared TURN credentials.
- internal/cmd/daemon.go + download.go: pass WebRTC config through to
  the engine.

Tests (8 new, all green; full suite 0 lint issues, 0 vet):
- buildMagnet free function: defaults-only, with extras, trim+empty-skip
- downloader method: WebRTC disabled keeps WSS out, enabled prepends them
- BuildICEServers: nil when disabled, STUN-only path, TURN+credentials
- NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC
  peer enabled, magnet contains wss://tracker.torrentclaw.com)

End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a
manual test once tracker.torrentclaw.com WSS is live.
2026-05-06 08:59:58 +02:00
Deivid Soto
6955b6144b chore(release): 0.7.0
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.7.0
- Update CHANGELOG.md
2026-04-10 19:18:38 +02:00
Deivid Soto
37fcb9fad9 feat(daemon): enhance service management with start, stop, restart, and status commands for Windows 2026-04-10 19:18:13 +02:00
Deivid Soto
debf77005f chore(release): 0.6.8
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.6.8
- Update CHANGELOG.md
2026-04-10 16:36:27 +02:00
Deivid Soto
f699b26fa6 feat(library): add server-driven file deletion with allow_delete config 2026-04-10 16:35:12 +02:00
Deivid Soto
8ad8a5ea47 chore(release): 0.6.7
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.6.7
- Update CHANGELOG.md
2026-04-10 11:47:58 +02:00
Deivid Soto
db316726fd feat(scan): always scan downloads + organize dirs, deduplicate child paths
ResolveScanPaths() collects downloads.dir, organize.movies_dir,
organize.tv_shows_dir, and library.scan_path (if set), then removes
paths that are subdirectories of a parent already in the list.

This ensures the daemon and CLI scan all configured dirs without
relying solely on scan_path being set.
2026-04-10 11:46:20 +02:00
Deivid Soto
b2ed81ee74 fix(docker): switch ffprobe download from johnvansickle.com to BtbN/FFmpeg-Builds
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
johnvansickle.com was unreachable from GitHub Actions runners (2 failed releases),
switching to BtbN static builds on GitHub CDN which are more reliable.
2026-04-09 19:25:28 +02:00
Deivid Soto
b3f2b3e64d chore(release): 0.6.6
- Bump version to 0.6.6
- Update CHANGELOG.md
2026-04-09 18:37:56 +02:00
Deivid Soto
f1b4f2e327 fix(stream): fix black screen on remote/Tailscale streaming
Three root-cause fixes for VLC showing a black screen when opening a
stream from a different network or via Tailscale:

1. PrioritizeTail: when VLC opens an MKV/MP4 stream it immediately seeks
   to the end of the file to read the container index (seekhead/moov
   atom). For active torrents those end-pieces aren't downloaded yet, so
   the reader blocks indefinitely. PrioritizeTail() opens a background
   reader positioned at the last 5 MB, keeping those pieces at high
   priority until ctx is cancelled or they finish downloading.

2. /health endpoint: GET /health returns a lightweight JSON response
   {"status":"ok","streaming":bool,...} so connectivity can be tested
   with a simple curl from any device before involving VLC.

3. Per-request logging: every incoming /stream request now logs the
   client IP and Range header, making it trivial to confirm whether
   remote/Tailscale clients are reaching the server at all.
2026-04-09 16:15:41 +02:00
Deivid Soto
7eaf357680 chore(release): 0.6.5
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.6.5
- Update CHANGELOG.md
2026-04-09 14:16:02 +02:00
Deivid Soto
db3e74a736 fix(upgrade): retry download on transient network errors with user feedback
Add downloadWithRetry with up to 3 attempts and quadratic backoff (5s, 20s)
to handle TLS timeouts and transient failures. Progress messages inform the
user of each failure and wait time before retrying.
2026-04-09 14:15:32 +02:00
Deivid Soto
29f4886a53 chore(release): 0.6.4
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.6.4
- Update CHANGELOG.md
2026-04-09 10:54:42 +02:00
Deivid Soto
8fae119903 fix(daemon): report error status when stream path is rejected 2026-04-09 10:54:14 +02:00
Deivid Soto
d7fa0af504 chore(release): 0.6.3
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.6.3
- Update CHANGELOG.md
2026-04-09 09:26:17 +02:00
Deivid Soto
fad53a5d84 fix(library): use native arm64 ffprobe on Apple Silicon (osx-arm-64) 2026-04-09 09:26:10 +02:00
Deivid Soto
bea73335a8 chore(release): 0.6.2
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.6.2
- Update CHANGELOG.md
2026-04-09 09:21:00 +02:00
Deivid Soto
db6d78d50a chore: ignore local config/ directory 2026-04-09 09:18:14 +02:00
Deivid Soto
228564eb7f feat(library): resilient scan for large libraries and better ffprobe errors
- Use a dedicated 10-minute HTTP client for library-sync so libraries
  with hundreds or thousands of items no longer time out
- Show actionable ffprobe-not-found error: detects Docker and suggests
  FFPROBE_PATH env var, config.toml setting, or package install
- Include static ffprobe binary in Docker image (johnvansickle.com)
- Bump version to 0.6.2
2026-04-09 09:13:38 +02:00
Deivid Soto
3fd19f1406 feat(wake): long-poll wake listener for instant CLI sync
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
CLI now holds a GET /api/internal/agent/wake connection open.
When the server calls triggerWake(userId) — on stream request,
download queue, pause, cancel, resume, scan, etc. — the CLI
receives the signal immediately and fires a sync cycle in <100ms
instead of waiting up to 10s for the next scheduled interval.

- Add WaitForWake(ctx) to Client using a no-timeout HTTP client
- Add runWakeListener goroutine to SyncClient (auto-reconnects)
- Start wake listener from SyncClient.Run()

Closes: sub-second stream latency from the web UI
2026-04-09 00:01:24 +02:00
Deivid Soto
ef4f38d324 fix: resolve deadlock, data races and path traversal vulnerabilities
- task.go: fix deadlock in ToStatusUpdate() — calling Percent() (which
  RLocks) while already holding RLock caused deadlock when a writer was
  waiting; compute percent inline instead
- usenet.go: fix data race in Cancel() — tracker and taskDir were read
  without the mutex while Download() writes them under it; read all
  fields under the same lock
- upnp.go: fix UPnP Remove() blocking shutdown — run cleanup in goroutine
  with 10s deadline (removeNATPMP worst case is 3s dial + 5s deadline)
- daemon.go: add path traversal protection for stream requests — validate
  sr.FilePath is within configured directories before os.Stat; defends
  against compromised API server sending arbitrary paths
- client.go: add wakeClient without timeout for long-poll wake endpoint
  where context controls cancellation
- sync.go: trigger immediate sync when entering watching mode so stream
  requests are picked up without waiting for the next scheduled interval
2026-04-08 23:36:18 +02:00
Deivid Soto
78c16c295e test: add comprehensive test suite for engine, agent and cmd packages
- Refactor download.go and stream.go with downloadDeps/streamDeps structs
  for dependency injection, enabling unit testing without real I/O
- download_test.go: 15 tests — input validation, mock downloaders, method
  selection, cobra Args, deadlock detection
- stream_test.go: input validation, noOpen flag, engine error handling
- client_test.go: context cancellation, timeout, full Sync roundtrip,
  watch-progress and HTTP error unwrapping
- sync_test.go: TriggerSync on watching transition, adjustInterval
- torrent_test.go: TorrentDownloader lifecycle without network
- stream_server_test.go: HTTP server lifecycle, SetFile/ClearFile,
  concurrent requests, Shutdown releases port, content-type
- manager_integration_test.go: full pipeline — success, torrent→debrid
  fallback, all-fail, multi-concurrent, ForceStart, OnTaskDone,
  recent-finished drain, cancel mid-download, organize
- usenet_test.go: Cancel/Pause race regression test (run with -race)
- daemon_test.go: isAllowedStreamPath table tests
- CI: split coverage gate to engine+agent only (50% threshold); cmd
  coverage still reported but not gated (interactive UI commands)
- lefthook: add pre-push hook with go test -race -count=1 -timeout=120s
2026-04-08 23:36:00 +02:00
Deivid Soto
b14ab98580 chore(release): 0.6.0
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.6.0
- Update CHANGELOG.md
2026-04-08 18:57:36 +02:00
Deivid Soto
5d4a67c7a2 feat(sync): replace WS+DO transport with unified HTTP sync
Replace the WebSocket + Cloudflare Durable Object architecture with a
single POST /sync endpoint. The CLI now operates autonomously with local
state (tasks.json) and syncs bidirectionally via adaptive-interval HTTP
polling (3s watching, 60s idle).

- Remove transport_ws, transport_hybrid, transport_http (~2,600 lines)
- Add SyncClient with adaptive interval loop
- Add LocalState for CLI-side task persistence
- Add TaskStateFromUpdate() helper (DRY)
- Extract finalize() to deduplicate processTask/processTaskRetry
- Consolidate shortID() into agent.ShortID (was in 3 packages)
- Wire GetActiveCount so `unarr status` shows active tasks
- Remove poll_interval, heartbeat_interval, ws_url from config
- Simplify ProgressReporter (sync replaces direct HTTP reporting)
2026-04-08 18:50:59 +02:00
Deivid Soto
2398707cc1 fix(ws): add ping/pong keepalive and read deadline to detect zombie connections
Without a SetReadDeadline, a silently dead WebSocket (e.g. Cloudflare
dropping the connection without a close frame) would block readLoop
forever. The daemon would appear connected but never receive tasks,
and never fall back to HTTP polling.

- Send RFC 6455 pings every 30s (resets Cloudflare's idle timer)
- SetReadDeadline of 45s, refreshed on every pong and text message
- SetWriteDeadline of 10s on all writes to prevent blocked sends
- On timeout, readLoop emits "disconnected" → HybridTransport falls
  back to HTTP and starts WS reconnection loop
2026-04-08 00:06:19 +02:00
Deivid Soto
56a386f4e2 chore(release): 0.5.5
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.5.5
- Update CHANGELOG.md
2026-04-07 23:33:24 +02:00
Deivid Soto
4d7362a567 fix(daemon): cancel watch reporter on stream switch and re-notify ready
- Register WatchReporter cancel funcs in streamRegistry so they get
  cancelled when switching to a different stream (prevents goroutine leak)
- Re-notify streamReady when the server is already serving the requested
  task (handles duplicate stream requests from the web UI)
- Rewrite tests for byte-based tracking semantics, remove dead
  parseRangeStart tests
2026-04-07 23:30:53 +02:00
Deivid Soto
c612ebb2e4 feat(stream): report duration and position in watch progress
EstimatedProgress now returns video duration in seconds (from ffprobe).
WatchReporter sends Position and Duration fields when available, giving
the server precise playback time instead of just a percentage.
2026-04-07 23:29:00 +02:00
Deivid Soto
2dfe144df1 feat(stream): trackingReader with byte-based progress and rate limiting
Replace Range-header-based progress tracking with a trackingReader that
measures actual bytes read per connection. This gives accurate playback
position even for local/NAS files where VLC buffers aggressively.

- Token bucket rate limiter at 2x video bitrate (from ffprobe)
- CAS loops for lock-free atomic progress updates without regression
- probeMediaInfo extracts bitrate + duration via ffprobe (3s timeout)
- Defense-in-depth: only probe regular files, reject FIFOs/devices
- Remove dead parseRangeStart function
- Consistent [stream] log prefix
2026-04-07 23:28:53 +02:00
Deivid Soto
64734cad1f feat(agent): send stream port and IPs in register request
Include StreamPort, LanIP, and TailscaleIP in RegisterRequest so the
server knows the agent's stream endpoints from the moment it registers,
not just after the first heartbeat. Align HeartbeatRequest field order
with RegisterRequest for consistency.
2026-04-07 23:28:41 +02:00
Deivid Soto
bfa8ec5f11 chore(release): 0.5.4
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.5.4
- Update CHANGELOG.md
2026-04-07 19:18:41 +02:00
Deivid Soto
264be4e309 fix(stream): use platform-specific socket options for Windows cross-compilation 2026-04-07 19:18:13 +02:00
Deivid Soto
55fb74c814 chore(release): 0.5.3
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.5.3
- Update CHANGELOG.md
2026-04-07 19:08:49 +02:00
Deivid Soto
5994a30447 feat(stream): persistent stream server with file swapping 2026-04-07 19:08:37 +02:00
Deivid Soto
080fdf4d76 chore(release): 0.5.2
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.5.2
- Update CHANGELOG.md
2026-04-07 17:06:04 +02:00
Deivid Soto
eb8f5e8b1a feat(stream): report multi-network URLs for smart resolution 2026-04-07 17:05:52 +02:00
Deivid Soto
dc1a21d8f0 chore(release): 0.5.1
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.5.1
- Update CHANGELOG.md
2026-04-07 16:19:38 +02:00
Deivid Soto
d2edc08a1e fix(stream): prevent duplicate events from killing active stream server 2026-04-07 16:19:01 +02:00
Deivid Soto
a857661b27 fix(daemon): report failed status on stream request errors 2026-04-07 12:39:22 +02:00
Deivid Soto
a9179dc758 feat(daemon): add on-demand library scan via heartbeat and WebSocket 2026-04-07 11:36:42 +02:00
Deivid Soto
4cf07c411c fix(daemon): use correct systemd user target and isolate test cache 2026-04-06 18:49:44 +02:00
Deivid Soto
6f81a2f3ea fix(agent): add retry with backoff and WebSocket connect for daemon registration 2026-04-06 17:26:32 +02:00
Deivid Soto
8388220dae chore(release): 0.5.0
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.5.0
- Update CHANGELOG.md
2026-04-06 10:16:57 +02:00
Deivid Soto
4d74b8cd8c test(mediainfo): add ffprobe download unit tests 2026-04-06 10:16:27 +02:00
Deivid Soto
eaf9d9d1c9 chore(release): add changelog generation and release automation 2026-04-06 10:16:01 +02:00
Deivid Soto
aa6acbabc9 feat(stream): add NAT-PMP port mapping for remote downloads
Replace anacrolix/upnp with huin/goupnp + custom NAT-PMP (RFC 6886)
implementation. NAT-PMP is tried first (faster, more compatible with
TP-Link routers), with UPnP-IGD SOAP as fallback. Gateway detection
reads /proc/net/route for accuracy. Includes unit tests with mock
NAT-PMP server and permanent e2e tests (build tag manual).
2026-04-06 10:09:07 +02:00
Deivid Soto
819c727bf5 feat(organize): use server metadata for file organization and subtitle handling 2026-04-05 23:36:01 +02:00
Deivid Soto
48e4fb9f7b fix(lint): remove unused newStubCmd function
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
2026-04-01 12:29:05 +02:00
Deivid Soto
4d35e197f0 feat(cli): add login command and refactor shared helpers 2026-04-01 12:20:51 +02:00
Deivid Soto
0dafeaa70d feat(stream): report watch progress to API via HTTP Range tracking
Track the highest byte offset served by the stream server to estimate
playback progress (0-100%). A WatchReporter goroutine sends progress
to POST /api/internal/agent/watch-progress every 10s during streaming.

- Add maxByteOffset + totalFileSize to StreamServer for Range tracking
- Add FileSize() to fileProvider interface (all 3 providers)
- New WatchReporter: periodic progress reporter tied to daemon context
- New WatchProgressUpdate type with optional progress/position/duration
- Wire reporter into all 3 stream paths (task stream, disk stream, active download stream)
2026-04-01 12:16:45 +02:00
Deivid Soto
932312fc56 chore(cli): remove moreseed stub command 2026-03-31 23:12:07 +02:00
Deivid Soto
ab3b393c22 chore(cli): remove redundant stub commands (monitor, open, add, compare) 2026-03-31 23:03:08 +02:00
Deivid Soto
d0dbfc3d12 fix(ci): fix lint errors and pin CI to Go 1.25
- Run gofmt on all files
- Export SetupUPnP to fix unused lint error
- Remove Go 1.26 from CI matrix (only test with 1.25)
2026-03-31 22:15:12 +02:00
Deivid Soto
3e0f3a5a64 feat(cli): upgrade command, rich status, and version cache
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
- Replace `upgrade` stub with real command (alias for `self-update`)
- Also register `update` as alias: `unarr update` works too
- Rewrite `status` to show full config, disk usage, daemon state, and
  update availability with colored sections
- Add version check cache (1h TTL) so `status` is instant on repeat runs
- Guard against division by zero on empty filesystems
- Guard against negative durations from clock skew
- Guard against stale PID via heartbeat recency check (2 min)
- Add comprehensive test coverage across agent, engine, upgrade, usenet,
  arr, library, mediaserver, and UI packages
- Improve Makefile coverage target to exclude cmd/ glue code
- Fix stream handler resource cleanup and ffprobe error handling
2026-03-31 22:05:43 +02:00
Deivid Soto
01d62ffa13 fix(progress): always report status transitions and poll for control signals 2026-03-31 16:55:50 +02:00
Deivid Soto
763e267bf8 chore(deps): bump Alpine 3.21→3.22, update CI actions and linter
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
- Dockerfile: alpine 3.21 → 3.22 (fewer CVEs per Docker Scout)
- release.yml: actions/checkout v4→v6, setup-go v5→v6, setup-buildx v3→v4
- ci.yml: golangci-lint v2.11.3 → v2.11.4
- DOCKERHUB.md: update Alpine version reference
2026-03-31 11:39:45 +02:00
Deivid Soto
f15eefc0ff ci(docker): remove dockerhub-description sync step 2026-03-31 11:30:40 +02:00
Deivid Soto
e4f45332ca ci(docker): add Docker Hub description sync and DOCKERHUB.md
Some checks failed
Release / release (push) Failing after 1s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
2026-03-31 10:24:14 +02:00
Deivid Soto
af08073aa8 Merge remote-tracking branch 'origin/main' 2026-03-31 10:21:16 +02:00
Deivid Soto
3e60a2a056 fix(docker): upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171 2026-03-31 10:20:30 +02:00
Deivid Soto
6d7c5d9174 Merge pull request #12 from torrentclaw/dependabot/github_actions/docker/metadata-action-6
ci(deps): bump docker/metadata-action from 5 to 6
2026-03-31 10:10:37 +02:00
Deivid Soto
b493456b92 Merge pull request #11 from torrentclaw/dependabot/github_actions/docker/setup-qemu-action-4
ci(deps): bump docker/setup-qemu-action from 3 to 4
2026-03-31 10:10:31 +02:00
Deivid Soto
125208e53b Merge pull request #10 from torrentclaw/dependabot/github_actions/docker/login-action-4
ci(deps): bump docker/login-action from 3 to 4
2026-03-31 10:10:25 +02:00
Deivid Soto
a184937287 Merge pull request #9 from torrentclaw/dependabot/github_actions/docker/build-push-action-7
ci(deps): bump docker/build-push-action from 6 to 7
2026-03-31 10:10:14 +02:00
Deivid Soto
b8bc4bcca5 Merge pull request #13 from torrentclaw/dependabot/github_actions/codecov/codecov-action-6
ci(deps): bump codecov/codecov-action from 5 to 6
2026-03-31 09:49:25 +02:00
dependabot[bot]
23d283587d ci(deps): bump docker/metadata-action from 5 to 6
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 5 to 6.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Commits](https://github.com/docker/metadata-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/metadata-action
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 23:58:14 +00:00
dependabot[bot]
085dfb0520 ci(deps): bump docker/setup-qemu-action from 3 to 4
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 3 to 4.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 23:58:11 +00:00
dependabot[bot]
a23d2ff336 ci(deps): bump docker/login-action from 3 to 4
Bumps [docker/login-action](https://github.com/docker/login-action) from 3 to 4.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 23:58:08 +00:00
dependabot[bot]
94be50755e ci(deps): bump docker/build-push-action from 6 to 7
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 6 to 7.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 23:58:05 +00:00
163 changed files with 26754 additions and 3007 deletions

105
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,105 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
name: Test
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v4
- name: Run tests
run: go test -v -race -count=1 ./...
build:
name: Build
runs-on: docker
container:
image: docker.io/library/golang:1.25
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: go build -o unarr ./cmd/unarr/
lint:
name: Lint
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v4
- 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
run: golangci-lint run ./...
coverage:
name: Coverage
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v4
- name: Install python3
run: apt-get update && apt-get install -y --no-install-recommends python3
- name: Run tests with coverage (all packages)
run: |
go test -race -coverprofile=coverage.out -covermode=atomic \
./internal/engine/... \
./internal/agent/... \
./internal/cmd/...
- name: Check coverage threshold (engine + agent)
run: |
# Threshold applies only to engine and agent — cmd contains interactive UI
# commands (config menus, daemon, auth browser) that are not unit-testable.
go test -race -coverprofile=coverage-core.out -covermode=atomic \
./internal/engine/... \
./internal/agent/...
COVERAGE=$(go tool cover -func=coverage-core.out | grep ^total | awk '{print $3}' | tr -d '%')
echo "Coverage on engine+agent: ${COVERAGE}%"
python3 -c "
coverage = float('${COVERAGE}')
threshold = 50.0
print(f'Coverage: {coverage:.1f}% (threshold: {threshold}%)')
if coverage < threshold:
print(f'ERROR: Coverage {coverage:.1f}% is below minimum {threshold}%')
exit(1)
else:
print('OK: Coverage meets minimum threshold')
"
vet:
name: Vet
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- 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,101 +0,0 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.25", "1.26"]
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: Run tests
run: go test -v -race -count=1 ./...
build:
name: Build
runs-on: ubuntu-latest
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"
- name: Build
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
run: go build -o unarr ./cmd/unarr/
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.3
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Run tests with coverage
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- 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
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Run go vet
run: go vet ./...

View file

@ -1,162 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
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@v4
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: torrentclaw/unarr
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
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"

6
.gitignore vendored
View file

@ -36,6 +36,12 @@ Thumbs.db
# GoReleaser # GoReleaser
dist/ dist/
dist-ffbinaries/
# Docker # Docker
tmp/ tmp/
config/
dist-ffbinaries/
# Claude Code: keep entirely local, do not track
.claude/

View file

@ -2,6 +2,14 @@ version: 2
project_name: unarr project_name: unarr
# Pre-build hook: fetch static ffmpeg + ffprobe per platform so each
# release tarball ships them adjacent to the unarr binary. ResolveFFmpeg /
# ResolveFFprobe pick them up via the "adjacent to executable" branch — no
# system install or runtime download needed.
before:
hooks:
- bash scripts/download-ffmpeg-static.sh
builds: builds:
- main: ./cmd/unarr/ - main: ./cmd/unarr/
binary: unarr binary: unarr
@ -18,13 +26,27 @@ builds:
- -s -w - -s -w
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}} - -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }} - -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: archives:
- format: tar.gz - formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides: format_overrides:
- goos: windows - goos: windows
format: zip formats: [zip]
files:
- LICENSE*
- README*
# Bundle the matching ffmpeg + ffprobe (filename includes .exe on Windows
# because download-ffmpeg-static.sh writes ffmpeg.exe / ffprobe.exe there).
- src: "dist-ffbinaries/{{ .Os }}-{{ .Arch }}/*"
dst: .
strip_parent: true
info:
mode: 0o755
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
@ -37,6 +59,22 @@ changelog:
- "^test:" - "^test:"
- "^chore:" - "^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) # Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN # Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
# brews: # brews:

0
.nojekyll Normal file
View file

View file

@ -5,53 +5,584 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased] ## [0.9.15] - 2026-05-27
### Added ### Added
- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`)
- Interactive config menu with 7 categories (`unarr config [category]`) - **sentry**: enhance error handling by skipping user input errors in CaptureError
- Migration wizard from Sonarr/Radarr/Prowlarr (`unarr migrate`) [pre-beta]
- Auto-detect instances via Docker, config files, port scan, Prowlarr
- Import download history and blocklist to avoid re-downloading
- Detect Plex/Jellyfin/Emby media servers and library paths
- Extract debrid tokens from *arr download clients
- JSON export with `--dry-run --json`
- Media server detection in `unarr init` (suggests library paths as download directory)
- `preferred_quality` setting in config (2160p/1080p/720p)
- Clean command to remove temp files, logs, and cached data (`unarr clean`)
- Daemon mode with background download management (`unarr start`)
- One-shot download command (`unarr download`)
- Stream to media player (`unarr stream`)
- Doctor command for diagnostics (`unarr doctor`)
- Status command for daemon monitoring (`unarr status`)
- Download engine with torrent support (debrid and usenet coming soon)
- File organization (Movies/TV Shows directory structure)
- Post-download verification
- Desktop notifications (Linux, macOS)
- Docker support with multi-stage build
- Cross-platform install scripts (shell, PowerShell)
- Dependabot for automated dependency updates
- golangci-lint configuration with gosec
### Changed ### Changed
- Renamed `internal/commands/` to `internal/cmd/`
## [0.1.0] - 2025-02-14 - **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 ### Added
- Initial release
- Search across 30+ torrent sources with advanced filters
- TrueSpec torrent inspection (quality, codec, seeds, score)
- Watch command (streaming providers + torrent alternatives)
- Popular and recent content browsing
- System statistics
- Interactive configuration
- JSON output mode (`--json`) for scripting
- Colored terminal output with `--no-color` support
- Homebrew tap distribution
- GoReleaser with UPX compression
- CI pipeline (test, build, lint, vet)
- Lefthook git hooks (gofmt, go vet, conventional commits)
[Unreleased]: https://github.com/torrentclaw/unarr/compare/v0.1.0...HEAD - **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
[0.1.0]: https://github.com/torrentclaw/unarr/releases/tag/v0.1.0
### 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
### Added
- **config**: set default values for WebRTC and transcoding in minimal TOML config
- **transcode**: dynamic H.264 level + HW probe + capability reporting
### Changed
- **streaming**: improve signal handling and remove unused components
### Fixed
- **self-update**: auto-restart live daemon after upgrade
- **streaming**: allow HLS sessions when webrtc disabled
### Other
- **gitignore**: add dist-ffbinaries to ignored files
- **release**: 0.8.1
## [0.8.0] - 2026-05-08
### Added
- **mediainfo**: ResolveFFmpeg + DownloadFFmpeg mirroring ffprobe pattern
- **release**: bundle ffmpeg + ffprobe in tarballs and Docker image
- **seed-file**: unarr-side handler for browser-on-demand seeding (Fase 4.7.c)
- **stream**: per-session quality cap from web
- **stream**: real-time transcoding for non-browser-decodable codecs
- **stream**: pion-based WebRTC byte streamer for browser playback
- **streaming**: seek-restart, single-session, idle sweeper, probe.json
- **streaming**: add HLS transport pipeline (daemon side)
- **streaming**: ffmpeg transcoding pipeline (direct play / fMP4 / HW accel)
- **torrent**: act as WebTorrent peer for browser ↔ unarr P2P streaming
- **wstracker-probe**: -seed FILE mode for browser ↔ unarr e2e validation
### Fixed
- **streaming**: bounded ffmpeg auto-restart + tmpdir gc + probe/stderr safety
- **transcoder**: force aac stereo 48khz + frag_duration for mse compat
- **transcoder**: force main profile + setparams Rec.709 + serveRange wait
- **transcoder**: correct scale filter + always force yuv420p
### Other
- **release**: 0.8.0
- **streaming**: post-review fixes — race lock, dead branch, stderr cap
- **torrent**: bump anacrolix log level Critical → Warning for visibility
## [0.7.0] - 2026-04-10
### Added
- **daemon**: enhance service management with start, stop, restart, and status commands for Windows
### Other
- **release**: 0.7.0
## [0.6.8] - 2026-04-10
### Added
- **library**: add server-driven file deletion with allow_delete config
### Other
- **release**: 0.6.8
## [0.6.7] - 2026-04-10
### Added
- **scan**: always scan downloads + organize dirs, deduplicate child paths
### Other
- **release**: 0.6.7
## [0.6.6] - 2026-04-09
### Fixed
- **docker**: switch ffprobe download from johnvansickle.com to BtbN/FFmpeg-Builds
- **stream**: fix black screen on remote/Tailscale streaming
### Other
- **release**: 0.6.6
## [0.6.5] - 2026-04-09
### Fixed
- **upgrade**: retry download on transient network errors with user feedback
### Other
- **release**: 0.6.5
## [0.6.4] - 2026-04-09
### Fixed
- **daemon**: report error status when stream path is rejected
### Other
- **release**: 0.6.4
## [0.6.3] - 2026-04-09
### Fixed
- **library**: use native arm64 ffprobe on Apple Silicon (osx-arm-64)
### Other
- **release**: 0.6.3
## [0.6.2] - 2026-04-09
### Added
- **library**: resilient scan for large libraries and better ffprobe errors
### Other
- **release**: 0.6.2
- ignore local config/ directory
## [0.6.1] - 2026-04-08
### Added
- **wake**: long-poll wake listener for instant CLI sync
### Fixed
- resolve deadlock, data races and path traversal vulnerabilities
## [0.6.0] - 2026-04-08
### Added
- **sync**: replace WS+DO transport with unified HTTP sync
### Fixed
- **ws**: add ping/pong keepalive and read deadline to detect zombie connections
### Other
- **release**: 0.6.0
## [0.5.5] - 2026-04-07
### Added
- **agent**: send stream port and IPs in register request
- **stream**: report duration and position in watch progress
- **stream**: trackingReader with byte-based progress and rate limiting
### Fixed
- **daemon**: cancel watch reporter on stream switch and re-notify ready
### Other
- **release**: 0.5.5
## [0.5.4] - 2026-04-07
### Fixed
- **stream**: use platform-specific socket options for Windows cross-compilation
### Other
- **release**: 0.5.4
## [0.5.3] - 2026-04-07
### Added
- **stream**: persistent stream server with file swapping
### Other
- **release**: 0.5.3
## [0.5.2] - 2026-04-07
### Added
- **stream**: report multi-network URLs for smart resolution
### Other
- **release**: 0.5.2
## [0.5.1] - 2026-04-07
### Added
- **daemon**: add on-demand library scan via heartbeat and WebSocket
### Fixed
- **agent**: add retry with backoff and WebSocket connect for daemon registration
- **daemon**: report failed status on stream request errors
- **daemon**: use correct systemd user target and isolate test cache
- **stream**: prevent duplicate events from killing active stream server
### Other
- **release**: 0.5.1
## [0.5.0] - 2026-04-06
### Added
- **organize**: use server metadata for file organization and subtitle handling
- **stream**: add NAT-PMP port mapping for remote downloads
### Other
- **release**: 0.5.0
- **release**: add changelog generation and release automation
## [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)
- **daemon**: add auto-scan, force start, and stall timeout default
- **debrid**: add HTTPS downloader for debrid direct URLs
- **stream**: UPnP port forwarding for remote video playback
- **usenet**: implement full NNTP download pipeline
- add migrate command, media server detection, and debrid auto-config
- replace setup with init wizard + interactive config menu
- add clean command to remove temp files, logs, and cached data
- add Sentry error reporting
- improve daemon resilience, streaming, and usenet downloads
- initial commit — unarr CLI
### Changed
- extract BuildSyncItems to library package, remove duplication
### Documentation
- improve CLI help, shell completion, and README
### Fixed
- **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
### Build
- 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
[0.6.8]: https://github.com/torrentclaw/unarr/compare/v0.6.7...v0.6.8
[0.6.7]: https://github.com/torrentclaw/unarr/compare/v0.6.6...v0.6.7
[0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6
[0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5
[0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4
[0.6.3]: https://github.com/torrentclaw/unarr/compare/v0.6.2...v0.6.3
[0.6.2]: https://github.com/torrentclaw/unarr/compare/v0.6.1...v0.6.2
[0.6.1]: https://github.com/torrentclaw/unarr/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/torrentclaw/unarr/compare/v0.5.5...v0.6.0
[0.5.5]: https://github.com/torrentclaw/unarr/compare/v0.5.4...v0.5.5
[0.5.4]: https://github.com/torrentclaw/unarr/compare/v0.5.3...v0.5.4
[0.5.3]: https://github.com/torrentclaw/unarr/compare/v0.5.2...v0.5.3
[0.5.2]: https://github.com/torrentclaw/unarr/compare/v0.5.1...v0.5.2
[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

200
DOCKERHUB.md Normal file
View file

@ -0,0 +1,200 @@
# unarr
**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.
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
> 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.
---
## Quick start
### 1. First-time setup (interactive wizard)
```bash
docker run -it --rm \
-v ~/.config/unarr:/config \
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
docker run -d --name unarr \
--restart unless-stopped \
--network host \
--read-only --memory 512m \
-v ~/.config/unarr:/config \
-v ~/Media:/downloads \
torrentclaw/unarr
```
That's it — `unarr` now runs headless, watching for jobs and managing downloads.
---
## Docker Compose
```yaml
services:
unarr:
image: torrentclaw/unarr:latest
container_name: unarr
restart: unless-stopped
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
- ./config:/config
- ~/Media:/downloads
- unarr-data:/data
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"
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 |
## Environment variables
| 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)** — full P2P performance, no port mapping:
```yaml
network_mode: host
```
**Bridge mode** — more isolated, but you must expose the BitTorrent ports:
```yaml
ports:
- "6881-6889:6881-6889/tcp"
- "6881-6889:6881-6889/udp"
```
## Running commands
Use `docker exec` for one-off commands while the daemon is running:
```bash
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 # diagnose config / connectivity
```
---
## Tags
| 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`) |
Pin a tag in production (`torrentclaw/unarr:0.9.0`) for reproducible deploys.
## 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 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.

View file

@ -16,9 +16,28 @@ ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/ RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/
# ---- Runtime stage ---- # ---- Runtime stage ----
FROM alpine:3.21 FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata # 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 HLS transcoding
# pipeline (libx264 + libfdk-aac alternatives included).
RUN apk upgrade --no-cache && \
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) # 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 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 .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 BINARY = unarr
SENTRY_DSN ?= SENTRY_DSN ?=
@ -19,10 +19,13 @@ test:
lint: lint:
golangci-lint run ./... golangci-lint run ./...
## Run tests with coverage report ## Run tests with coverage report (excludes CLI layer — cmd/ is glue code)
COVER_PKGS = $(shell go list ./... | grep -v '/cmd')
coverage: coverage:
go test -race -coverprofile=coverage.out -covermode=atomic ./... go test -race -coverprofile=coverage.out -covermode=atomic $(COVER_PKGS)
go tool cover -func=coverage.out @echo "──────────────────────────────────────"
@go tool cover -func=coverage.out | tail -1
@echo "──────────────────────────────────────"
go tool cover -html=coverage.out -o coverage.html go tool cover -html=coverage.out -o coverage.html
## Format code ## Format code
@ -45,6 +48,42 @@ install-hooks:
install: install:
go install ./cmd/unarr/ go install ./cmd/unarr/
## Preview changelog for next release
changelog:
@git-cliff --unreleased --strip header
## Create a release: make release-patch, release-minor, release-major, or release V=0.5.0
release:
@test -n "$(V)" || { echo "Usage: make release V=0.5.0"; exit 1; }
@./scripts/release.sh $(V)
release-patch:
@./scripts/release.sh patch
release-minor:
@./scripts/release.sh minor
release-major:
@./scripts/release.sh major
## Preview release without making changes
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 ## Remove generated files
clean: clean:
rm -f $(BINARY) coverage.out coverage.html rm -f $(BINARY) coverage.out coverage.html

243
README.md
View file

@ -11,9 +11,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![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) [![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 --> <!-- GIF demo placeholder -->
<!-- ![unarr Demo](docs/demo.gif) --> <!-- ![unarr Demo](docs/demo.gif) -->
@ -171,6 +171,9 @@ unarr start
| `unarr status` | Show daemon status and active downloads | | `unarr status` | Show daemon status and active downloads |
| `unarr daemon install` | Install as system service (systemd/launchd) | | `unarr daemon install` | Install as system service (systemd/launchd) |
| `unarr daemon uninstall` | Remove the system service | | `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 ### 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) - Linux: `~/.config/systemd/user/unarr.service` (systemd)
- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd) - 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 ## Diagnostics
```bash ```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. `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 ## Clean
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting. 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] [daemon]
poll_interval = "30s" poll_interval = "30s"
heartbeat_interval = "30s" heartbeat_interval = "30s"
auto_upgrade = true # apply server-flagged upgrades in-place (since 0.9.6)
[notifications] [notifications]
enabled = true enabled = true
@ -382,6 +485,142 @@ enabled = true
country = "US" country = "US"
``` ```
### Streaming reference
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.transcode]
enabled = true # master switch
hw_accel = "auto" # auto | none | nvenc | qsv | vaapi | videotoolbox
preset = "veryfast" # libx264 preset
video_bitrate = "" # e.g. "5M" caps -b:v; empty = engine fallback (5M)
audio_bitrate = "192k" # e.g. "128k", "192k", "256k"
max_height = 0 # 0 = no cap; e.g. 720 forces 720p max
max_concurrent = 2 # max simultaneous ffmpeg processes
```
#### `[downloads.transcode]`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `true` | Real-time HLS transcoding when source codec is browser-incompatible (HEVC, AV1, AC3, DTS). Requires `ffmpeg` + `ffprobe` on PATH. |
| `hw_accel` | string | `"auto"` | Hardware accel: `"auto"`, `"none"`, `"nvenc"` (NVIDIA), `"qsv"` (Intel), `"vaapi"` (Linux), `"videotoolbox"` (macOS). |
| `preset` | string | `"veryfast"` | libx264 preset. Slower preset = smaller files but higher CPU. Options: `ultrafast`, `superfast`, `veryfast`, `faster`, `fast`, `medium`, `slow`, `slower`, `veryslow`. |
| `video_bitrate` | string | `""` | E.g. `"5M"` caps `-b:v`. Empty falls back to the engine default (`5M`). |
| `audio_bitrate` | string | `"192k"` | E.g. `"128k"`, `"256k"`. |
| `max_height` | int | `0` | `0` = no cap. E.g. `720` forces 720p max — useful on weak GPUs. |
| `max_concurrent` | int | `2` | Max simultaneous ffmpeg processes. Increase if hosting multiple users on a beefy box. |
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
Environment variables override config file values: 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) - **Non-root Docker** — Container runs as unprivileged user (UID 1000)
- **Dependency scanning** — Automated via Dependabot - **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 ## Disclosure Policy
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous. We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.

79
cliff.toml Normal file
View file

@ -0,0 +1,79 @@
# git-cliff configuration
# https://git-cliff.org/docs/configuration
[changelog]
header = """# Changelog
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).\n
"""
body = """
{%- macro remote_url() -%}
https://github.com/torrentclaw/unarr
{%- endmacro -%}
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{%- else -%}
## [Unreleased]
{%- endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
- **{{ commit.scope }}**: {{ commit.message }}
{%- if commit.breaking %} (**BREAKING**){% endif %}
{%- endfor -%}
{% for commit in commits %}
{%- if not commit.scope %}
- {{ commit.message }}
{%- if commit.breaking %} (**BREAKING**){% endif %}
{%- endif %}
{%- endfor %}
{% endfor %}
"""
footer = """
{%- macro remote_url() -%}
https://github.com/torrentclaw/unarr
{%- endmacro -%}
{% for release in releases -%}
{% if release.version -%}
{% if release.previous.version -%}
[{{ release.version | trim_start_matches(pat="v") }}]: {{ self::remote_url() }}/compare/{{ release.previous.version }}...{{ release.version }}
{% else -%}
[{{ release.version | trim_start_matches(pat="v") }}]: {{ self::remote_url() }}/releases/tag/{{ release.version }}
{% endif -%}
{% else -%}
{% if release.previous.version -%}
[Unreleased]: {{ self::remote_url() }}/compare/{{ release.previous.version }}...HEAD
{% endif -%}
{% endif -%}
{% endfor %}
"""
trim = true
[git]
conventional_commits = true
filter_unconventional = true
split_commits = false
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^perf", group = "Performance" },
{ message = "^refactor", group = "Changed" },
{ message = "^style", group = "Changed" },
{ message = "^doc", group = "Documentation" },
{ message = "^ci", group = "CI/CD" },
{ message = "^chore\\(deps\\)", skip = true },
{ message = "^chore", group = "Other" },
{ message = "^test", skip = true },
]
protect_breaking_commits = false
filter_commits = false
tag_pattern = "v[0-9].*"
sort_commits = "newest"

18
go.mod
View file

@ -7,17 +7,17 @@ require (
github.com/anacrolix/dht/v2 v2.23.0 github.com/anacrolix/dht/v2 v2.23.0
github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb github.com/anacrolix/log v0.17.1-0.20251118025802-918f1157b7bb
github.com/anacrolix/torrent v1.61.0 github.com/anacrolix/torrent v1.61.0
github.com/anacrolix/upnp v0.1.4
github.com/charmbracelet/huh v1.0.0 github.com/charmbracelet/huh v1.0.0
github.com/fatih/color v1.19.0 github.com/fatih/color v1.19.0
github.com/getsentry/sentry-go v0.44.1 github.com/getsentry/sentry-go v0.44.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/huin/goupnp v1.3.0
github.com/olekukonko/tablewriter v1.1.4 github.com/olekukonko/tablewriter v1.1.4
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0 github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.41.0 golang.org/x/term v0.43.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
) )
require ( require (
@ -35,6 +35,7 @@ require (
github.com/anacrolix/multiless v0.4.0 // indirect github.com/anacrolix/multiless v0.4.0 // indirect
github.com/anacrolix/stm v0.5.0 // indirect github.com/anacrolix/stm v0.5.0 // indirect
github.com/anacrolix/sync v0.6.0 // indirect github.com/anacrolix/sync v0.6.0 // indirect
github.com/anacrolix/upnp v0.1.4 // indirect
github.com/anacrolix/utp v0.2.0 // indirect github.com/anacrolix/utp v0.2.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@ -68,6 +69,7 @@ require (
github.com/google/btree v1.1.3 // indirect github.com/google/btree v1.1.3 // indirect
github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/huandu/xstrings v1.5.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@ -120,12 +122,14 @@ require (
go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace 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/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/sync v0.20.0 // indirect
golang.org/x/sys v0.42.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 lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

36
go.sum
View file

@ -260,6 +260,8 @@ github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc=
github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
@ -471,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-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-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.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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= 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-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-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
@ -483,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/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.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.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= 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-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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -498,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-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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= 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-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 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= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -530,18 +532,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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.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.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= 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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -552,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-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.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.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.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= 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-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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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/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.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -585,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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/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= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=

View file

@ -12,25 +12,64 @@ import (
) )
// Client communicates with the /api/internal/agent/* endpoints. // 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 { type Client struct {
baseURL string pool *MirrorPool
apiKey string apiKey string
httpClient *http.Client httpClient *http.Client
userAgent string // wakeClient has no built-in timeout — used exclusively for the long-poll
// wake endpoint where the context controls cancellation.
wakeClient *http.Client
// librarySyncClient has a generous timeout for library-sync calls which can
// take several minutes when syncing hundreds or thousands of items.
librarySyncClient *http.Client
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 { 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{ return &Client{
baseURL: baseURL, pool: NewMirrorPool(baseURL, extras),
apiKey: apiKey, apiKey: apiKey,
httpClient: &http.Client{ httpClient: &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
userAgent: userAgent, // wakeClient has no built-in timeout — the context controls it.
// The server holds the connection for up to 28s before responding.
wakeClient: &http.Client{},
// librarySyncClient uses a 10-minute timeout to handle large libraries
// (hundreds or thousands of items) where ffprobe scanning alone can take
// several minutes before the HTTP request is even sent.
librarySyncClient: &http.Client{Timeout: 10 * time.Minute},
userAgent: userAgent,
} }
} }
// 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. // Register registers the CLI agent with the server and returns user info + features.
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) { func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
var resp RegisterResponse var resp RegisterResponse
@ -40,27 +79,6 @@ func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterRe
return &resp, nil return &resp, nil
} }
// Heartbeat sends a periodic keep-alive signal and returns server directives.
func (c *Client) Heartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
var resp HeartbeatResponse
if err := c.doPost(ctx, "/api/internal/agent/heartbeat", req, &resp); err != nil {
return nil, fmt.Errorf("heartbeat: %w", err)
}
return &resp, nil
}
// ClaimTasks polls for pending download tasks and claims them atomically.
// Also returns any stream requests for completed downloads.
func (c *Client) ClaimTasks(ctx context.Context, agentID string) (*TasksResponse, error) {
url := fmt.Sprintf("/api/internal/agent/tasks?agentId=%s", agentID)
var resp TasksResponse
if err := c.doGet(ctx, url, &resp); err != nil {
return nil, fmt.Errorf("claim tasks: %w", err)
}
return &resp, nil
}
// ReportStatus reports download progress or completion for a task.
// Deregister notifies the server that the agent is shutting down. // Deregister notifies the server that the agent is shutting down.
func (c *Client) Deregister(ctx context.Context, agentID string) error { func (c *Client) Deregister(ctx context.Context, agentID string) error {
req := struct { req := struct {
@ -73,6 +91,45 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
return nil 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. // ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) { func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse var resp StatusResponse
@ -91,6 +148,16 @@ func (c *Client) BatchReportStatus(ctx context.Context, updates []StatusUpdate)
return &resp, nil return &resp, nil
} }
// Sync sends the CLI's full state and receives all pending server actions.
// This is the single endpoint for bidirectional state synchronization.
func (c *Client) Sync(ctx context.Context, req SyncRequest) (*SyncResponse, error) {
var resp SyncResponse
if err := c.doPost(ctx, "/api/internal/agent/sync", req, &resp); err != nil {
return nil, fmt.Errorf("sync: %w", err)
}
return &resp, nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Usenet endpoints // Usenet endpoints
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -107,30 +174,35 @@ func (c *Client) SearchNzbs(ctx context.Context, params NzbSearchParams) (*NzbSe
// DownloadNzb downloads the NZB file for the given nzbId. // DownloadNzb downloads the NZB file for the given nzbId.
// Returns the raw NZB XML bytes. // Returns the raw NZB XML bytes.
func (c *Client) DownloadNzb(ctx context.Context, nzbID string) ([]byte, error) { 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) var out []byte
if err != nil { err := c.withMirrorFailover(func(base string) error {
return nil, fmt.Errorf("create request: %w", err) req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
} if err != nil {
c.setHeaders(req) return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req) resp, err := c.httpClient.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return fmt.Errorf("request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 400 { if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return nil, fmt.Errorf("nzb download error %d: %s", resp.StatusCode, string(body)) return &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
} }
data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit
if err != nil { if err != nil {
return nil, fmt.Errorf("read nzb: %w", err) return fmt.Errorf("read nzb: %w", err)
} }
return data, nil out = data
return nil
})
return out, err
} }
// GetUsenetCredentials fetches NNTP connection credentials. // GetUsenetCredentials fetches NNTP connection credentials.
@ -170,54 +242,154 @@ func (c *Client) BatchDownload(ctx context.Context, req BatchDownloadRequest) (*
} }
// SyncLibrary sends scanned library items to the server for matching and upgrade discovery. // SyncLibrary sends scanned library items to the server for matching and upgrade discovery.
// Uses a 10-minute timeout client to handle large libraries where scanning can take several minutes.
func (c *Client) SyncLibrary(ctx context.Context, req LibrarySyncRequest) (*LibrarySyncResponse, error) { func (c *Client) SyncLibrary(ctx context.Context, req LibrarySyncRequest) (*LibrarySyncResponse, error) {
var resp LibrarySyncResponse var resp LibrarySyncResponse
if err := c.doPost(ctx, "/api/internal/agent/library-sync", req, &resp); err != nil { if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/library-sync", req, &resp); err != nil {
return nil, fmt.Errorf("library sync: %w", err) return nil, fmt.Errorf("library sync: %w", err)
} }
return &resp, nil return &resp, nil
} }
// doPost sends a JSON POST request and decodes the response. // ReportWatchProgress sends playback position to the server for watch tracking.
func (c *Client) ReportWatchProgress(ctx context.Context, update WatchProgressUpdate) error {
var resp WatchProgressResponse
if err := c.doPost(ctx, "/api/internal/agent/watch-progress", update, &resp); err != nil {
return fmt.Errorf("watch progress: %w", err)
}
return nil
}
// 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) {
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 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 &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 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.
func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error { func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error {
return c.doPostWith(ctx, c.httpClient, path, body, dst)
}
// 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) jsonBody, err := json.Marshal(body)
if err != nil { if err != nil {
return fmt.Errorf("marshal body: %w", err) return fmt.Errorf("marshal body: %w", err)
} }
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody)) return c.withMirrorFailover(func(base string) error {
if err != nil { req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(jsonBody))
return fmt.Errorf("create request: %w", err) if err != nil {
} return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req) c.setHeaders(req)
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req) resp, err := hc.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("request failed: %w", err) return fmt.Errorf("request failed: %w", err)
} }
defer resp.Body.Close() defer resp.Body.Close()
return c.handleResponse(resp, dst) return c.handleResponse(resp, dst)
})
} }
// doGet sends a GET request and decodes the response. // doGet sends a GET request and decodes the response.
func (c *Client) doGet(ctx context.Context, path string, dst any) error { func (c *Client) doGet(ctx context.Context, path string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) return c.withMirrorFailover(func(base string) error {
if err != nil { req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
return fmt.Errorf("create request: %w", err) 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) var lastErr error
for i := 0; i < attempts; i++ {
resp, err := c.httpClient.Do(req) base := c.baseURL()
if err != nil { err := fn(base)
return fmt.Errorf("request failed: %w", err) 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 lastErr
return c.handleResponse(resp, dst)
} }
func (c *Client) setHeaders(req *http.Request) { func (c *Client) setHeaders(req *http.Request) {
@ -237,14 +409,14 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error {
// Try to parse as JSON error // Try to parse as JSON error
var errResp ErrorResponse var errResp ErrorResponse
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" { if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
return fmt.Errorf("API error %d: %s", resp.StatusCode, errResp.Error) return &HTTPError{StatusCode: resp.StatusCode, Message: errResp.Error}
} }
// Non-JSON response (e.g. HTML error page) — truncate to something readable // Non-JSON response (e.g. HTML error page) — truncate to something readable
msg := string(body) msg := string(body)
if len(msg) > 120 || strings.Contains(msg, "<html") || strings.Contains(msg, "<!DOCTYPE") { if len(msg) > 120 || strings.Contains(msg, "<html") || strings.Contains(msg, "<!DOCTYPE") {
msg = fmt.Sprintf("server returned %s (non-JSON response, likely a server error)", resp.Status) msg = fmt.Sprintf("server returned %s (non-JSON response, likely a server error)", resp.Status)
} }
return fmt.Errorf("API error %d: %s", resp.StatusCode, msg) return &HTTPError{StatusCode: resp.StatusCode, Message: msg}
} }
if dst != nil { if dst != nil {

View file

@ -3,9 +3,11 @@ package agent
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
) )
func TestRegister(t *testing.T) { func TestRegister(t *testing.T) {
@ -72,70 +74,6 @@ func TestRegister(t *testing.T) {
} }
} }
func TestHeartbeat(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/heartbeat" {
t.Errorf("path = %s, want /api/internal/agent/heartbeat", r.URL.Path)
}
var req HeartbeatRequest
json.NewDecoder(r.Body).Decode(&req)
if req.AgentID != "agent-123" {
t.Errorf("agentId = %q, want agent-123", req.AgentID)
}
json.NewEncoder(w).Encode(HeartbeatResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "agent-123"})
if err != nil {
t.Fatalf("Heartbeat failed: %v", err)
}
if !resp.Success {
t.Error("expected success=true")
}
}
func TestClaimTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.URL.Query().Get("agentId") != "agent-123" {
t.Errorf("agentId param = %q, want agent-123", r.URL.Query().Get("agentId"))
}
json.NewEncoder(w).Encode(TasksResponse{
Tasks: []Task{
{
ID: "task-uuid-1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix (1999)",
PreferredMethod: "auto",
},
},
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ClaimTasks(context.Background(), "agent-123")
if err != nil {
t.Fatalf("ClaimTasks failed: %v", err)
}
if len(resp.Tasks) != 1 {
t.Fatalf("len(tasks) = %d, want 1", len(resp.Tasks))
}
if resp.Tasks[0].ID != "task-uuid-1" {
t.Errorf("task.ID = %q, want task-uuid-1", resp.Tasks[0].ID)
}
if resp.Tasks[0].InfoHash != "abc123def456abc123def456abc123def456abc1" {
t.Errorf("task.InfoHash = %q", resp.Tasks[0].InfoHash)
}
if resp.Tasks[0].PreferredMethod != "auto" {
t.Errorf("task.PreferredMethod = %q, want auto", resp.Tasks[0].PreferredMethod)
}
}
func TestReportStatus(t *testing.T) { func TestReportStatus(t *testing.T) {
var received StatusUpdate var received StatusUpdate
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -173,22 +111,6 @@ func TestReportStatus(t *testing.T) {
} }
} }
func TestClaimTasksEmpty(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(TasksResponse{Tasks: []Task{}})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ClaimTasks(context.Background(), "agent-123")
if err != nil {
t.Fatalf("ClaimTasks failed: %v", err)
}
if len(resp.Tasks) != 0 {
t.Errorf("expected empty tasks, got %d", len(resp.Tasks))
}
}
func TestAPIError(t *testing.T) { func TestAPIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
@ -279,48 +201,527 @@ func TestUserAgent(t *testing.T) {
if r.Header.Get("User-Agent") != "unarr/0.2.0" { if r.Header.Get("User-Agent") != "unarr/0.2.0" {
t.Errorf("User-Agent = %q, want unarr/0.2.0", r.Header.Get("User-Agent")) t.Errorf("User-Agent = %q, want unarr/0.2.0", r.Header.Get("User-Agent"))
} }
json.NewEncoder(w).Encode(HeartbeatResponse{Success: true}) json.NewEncoder(w).Encode(RegisterResponse{Success: true})
})) }))
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr/0.2.0") c := NewClient(srv.URL, "test-key", "unarr/0.2.0")
c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "x"}) c.Register(context.Background(), RegisterRequest{AgentID: "x"})
} }
func TestHeartbeatWithUpgradeSignal(t *testing.T) { func TestDeregister(t *testing.T) {
var received struct {
AgentID string `json:"agentId"`
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(HeartbeatResponse{ if r.URL.Path != "/api/internal/agent/deregister" {
Success: true, t.Errorf("path = %s", r.URL.Path)
Upgrade: &UpgradeSignal{Version: "2.0.0"}, }
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(StatusResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
err := c.Deregister(context.Background(), "agent-42")
if err != nil {
t.Fatalf("Deregister failed: %v", err)
}
if received.AgentID != "agent-42" {
t.Errorf("agentId = %q, want agent-42", received.AgentID)
}
}
func TestBatchReportStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/status" {
t.Errorf("path = %s", r.URL.Path)
}
var req BatchStatusRequest
json.NewDecoder(r.Body).Decode(&req)
if len(req.Updates) != 2 {
t.Errorf("expected 2 updates, got %d", len(req.Updates))
}
json.NewEncoder(w).Encode(BatchStatusResponse{
Results: []StatusResponse{
{Success: true},
{Success: true, Cancelled: true},
},
}) })
})) }))
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test") c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "agent-1"}) resp, err := c.BatchReportStatus(context.Background(), []StatusUpdate{
{TaskID: "t1", Status: "downloading"},
{TaskID: "t2", Status: "completed"},
})
if err != nil { if err != nil {
t.Fatalf("Heartbeat failed: %v", err) t.Fatalf("BatchReportStatus failed: %v", err)
} }
if resp.Upgrade == nil { if len(resp.Results) != 2 {
t.Fatal("expected upgrade signal, got nil") t.Fatalf("expected 2 results, got %d", len(resp.Results))
} }
if resp.Upgrade.Version != "2.0.0" { if !resp.Results[1].Cancelled {
t.Errorf("upgrade version = %q, want 2.0.0", resp.Upgrade.Version) t.Error("expected result[1].Cancelled=true")
} }
} }
func TestHeartbeatWithoutUpgradeSignal(t *testing.T) { func TestSearchNzbs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(HeartbeatResponse{Success: true}) if r.URL.Path != "/api/internal/agent/nzb-search" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewEncoder(w).Encode(NzbSearchResponse{
Results: []NzbSearchResult{
{NzbID: "nzb-1", Title: "Movie.2023.1080p"},
},
})
})) }))
defer srv.Close() defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test") c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "agent-1"}) resp, err := c.SearchNzbs(context.Background(), NzbSearchParams{Query: "Movie"})
if err != nil { if err != nil {
t.Fatalf("Heartbeat failed: %v", err) t.Fatalf("SearchNzbs failed: %v", err)
} }
if resp.Upgrade != nil { if len(resp.Results) != 1 {
t.Errorf("expected no upgrade signal, got %+v", resp.Upgrade) t.Fatalf("expected 1 result, got %d", len(resp.Results))
}
if resp.Results[0].NzbID != "nzb-1" {
t.Errorf("nzb ID = %q, want nzb-1", resp.Results[0].NzbID)
}
}
func TestDownloadNzb(t *testing.T) {
nzbContent := []byte(`<?xml version="1.0"?><nzb><file>test</file></nzb>`)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/nzb-download" {
t.Errorf("path = %s", r.URL.Path)
}
if r.URL.Query().Get("nzbId") != "nzb-42" {
t.Errorf("nzbId = %q, want nzb-42", r.URL.Query().Get("nzbId"))
}
w.Write(nzbContent)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
data, err := c.DownloadNzb(context.Background(), "nzb-42")
if err != nil {
t.Fatalf("DownloadNzb failed: %v", err)
}
if string(data) != string(nzbContent) {
t.Errorf("nzb content mismatch")
}
}
func TestDownloadNzbError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("NZB not found"))
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.DownloadNzb(context.Background(), "bad-id")
if err == nil {
t.Fatal("expected error for 404 response")
}
}
func TestGetUsenetCredentials(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/usenet-credentials" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewEncoder(w).Encode(UsenetCredentials{
Host: "news.example.com",
Port: 563,
SSL: true,
Username: "user1",
Password: "pass1",
MaxConnections: 10,
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
creds, err := c.GetUsenetCredentials(context.Background())
if err != nil {
t.Fatalf("GetUsenetCredentials failed: %v", err)
}
if creds.Host != "news.example.com" {
t.Errorf("host = %q, want news.example.com", creds.Host)
}
if creds.Username != "user1" {
t.Errorf("username = %q, want user1", creds.Username)
}
if creds.MaxConnections != 10 {
t.Errorf("maxConnections = %d, want 10", creds.MaxConnections)
}
}
func TestGetUsenetUsage(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/usenet-usage" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewEncoder(w).Encode(UsenetUsageResponse{
UsedBytes: 5368709120,
QuotaBytes: 10737418240,
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
usage, err := c.GetUsenetUsage(context.Background())
if err != nil {
t.Fatalf("GetUsenetUsage failed: %v", err)
}
if usage.UsedBytes != 5368709120 {
t.Errorf("usedBytes = %d", usage.UsedBytes)
}
}
func TestConfigureDebrid(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/debrid-config" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewEncoder(w).Encode(ConfigureDebridResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.ConfigureDebrid(context.Background(), ConfigureDebridRequest{
Provider: "real-debrid",
Token: "rd-token-123",
})
if err != nil {
t.Fatalf("ConfigureDebrid failed: %v", err)
}
if !resp.Success {
t.Error("expected success=true")
}
}
func TestBatchDownload(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/batch-download" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewEncoder(w).Encode(BatchDownloadResponse{
Queued: 3,
NotFound: 1,
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.BatchDownload(context.Background(), BatchDownloadRequest{})
if err != nil {
t.Fatalf("BatchDownload failed: %v", err)
}
if resp.Queued != 3 {
t.Errorf("queued = %d, want 3", resp.Queued)
}
}
func TestSyncLibrary(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/library-sync" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewEncoder(w).Encode(LibrarySyncResponse{
Matched: 10,
Synced: 15,
Removed: 2,
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.SyncLibrary(context.Background(), LibrarySyncRequest{})
if err != nil {
t.Fatalf("SyncLibrary failed: %v", err)
}
if resp.Matched != 10 {
t.Errorf("matched = %d, want 10", resp.Matched)
}
if resp.Synced != 15 {
t.Errorf("synced = %d, want 15", resp.Synced)
}
}
func TestHTMLErrorResponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadGateway)
w.Write([]byte("<html><body>502 Bad Gateway</body></html>"))
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.Register(context.Background(), RegisterRequest{AgentID: "x"})
if err == nil {
t.Fatal("expected error for HTML error page")
}
}
func TestClient_ContextCancelled(t *testing.T) {
// Servidor que bloquea hasta que el cliente se desconecta
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancelar inmediatamente
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.Register(ctx, RegisterRequest{AgentID: "x"})
if err == nil {
t.Fatal("expected error when context is cancelled")
}
}
func TestClient_SlowServer_Timeout(t *testing.T) {
// Servidor que tarda más que el timeout del cliente.
// Usa time.Sleep para que el handler termine limpiamente cuando el server cierra.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(500 * time.Millisecond) // más largo que el timeout del cliente (50ms)
}))
defer srv.Close()
// Crear cliente con timeout muy corto
c := &Client{
pool: NewMirrorPool(srv.URL, nil),
apiKey: "test-key",
httpClient: &http.Client{
Timeout: 50 * time.Millisecond,
},
userAgent: "unarr-test",
}
_, err := c.Register(context.Background(), RegisterRequest{AgentID: "timeout-test"})
if err == nil {
t.Fatal("expected timeout error from slow server")
}
}
func TestClient_Sync_FullRequest(t *testing.T) {
var received SyncRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/sync" {
t.Errorf("path = %s, want /api/internal/agent/sync", r.URL.Path)
}
if r.Method != http.MethodPost {
t.Errorf("method = %s, want POST", r.Method)
}
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(SyncResponse{
NewTasks: []Task{
{ID: "task-from-server", InfoHash: "abc123def456abc123def456abc123def456abc1"},
},
Watching: true,
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Sync(context.Background(), SyncRequest{
AgentID: "agent-sync-1",
Version: "0.6.0",
OS: "linux",
Arch: "amd64",
FreeSlots: 2,
DiskFreeBytes: 10 << 30, // 10 GB
})
if err != nil {
t.Fatalf("Sync failed: %v", err)
}
if len(resp.NewTasks) != 1 {
t.Fatalf("expected 1 new task, got %d", len(resp.NewTasks))
}
if resp.NewTasks[0].ID != "task-from-server" {
t.Errorf("task ID = %q, want task-from-server", resp.NewTasks[0].ID)
}
if !resp.Watching {
t.Error("expected watching=true")
}
if received.AgentID != "agent-sync-1" {
t.Errorf("received.AgentID = %q, want agent-sync-1", received.AgentID)
}
if received.FreeSlots != 2 {
t.Errorf("received.FreeSlots = %d, want 2", received.FreeSlots)
}
}
func TestClient_ReportWatchProgress(t *testing.T) {
var received WatchProgressUpdate
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/watch-progress" {
t.Errorf("path = %s", r.URL.Path)
}
json.NewDecoder(r.Body).Decode(&received)
json.NewEncoder(w).Encode(WatchProgressResponse{Success: true})
}))
defer srv.Close()
pct := 42
c := NewClient(srv.URL, "test-key", "unarr-test")
err := c.ReportWatchProgress(context.Background(), WatchProgressUpdate{
TaskID: "task-watch-001",
Source: "range",
Progress: &pct,
})
if err != nil {
t.Fatalf("ReportWatchProgress failed: %v", err)
}
if received.TaskID != "task-watch-001" {
t.Errorf("taskID = %q, want task-watch-001", received.TaskID)
}
if received.Progress == nil || *received.Progress != 42 {
t.Errorf("progress = %v, want 42", received.Progress)
}
}
func TestClient_HTTPError_PlainText(t *testing.T) {
// Error 500 con body plano (no JSON ni HTML largo)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal server error"))
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.Register(context.Background(), RegisterRequest{AgentID: "x"})
if err == nil {
t.Fatal("expected error for 500 response")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) {
t.Fatalf("expected *HTTPError (possibly wrapped), got %T: %v", err, err)
}
if httpErr.StatusCode != 500 {
t.Errorf("StatusCode = %d, want 500", httpErr.StatusCode)
}
}
// ---------------------------------------------------------------------------
// WaitForWake tests
// ---------------------------------------------------------------------------
func TestWaitForWake_ReturnsTrue_OnWakeSignal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/wake" {
t.Errorf("path = %s, want /api/internal/agent/wake", r.URL.Path)
}
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("auth = %q", r.Header.Get("Authorization"))
}
json.NewEncoder(w).Encode(map[string]bool{"wake": true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
woke, err := c.WaitForWake(context.Background())
if err != nil {
t.Fatalf("WaitForWake failed: %v", err)
}
if !woke {
t.Error("expected wake=true")
}
}
func TestWaitForWake_ReturnsFalse_OnTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Server returns wake=false (long-poll timeout)
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
woke, err := c.WaitForWake(context.Background())
if err != nil {
t.Fatalf("WaitForWake failed: %v", err)
}
if woke {
t.Error("expected wake=false on server timeout")
}
}
func TestWaitForWake_Error_OnUnauthorized(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "Invalid API key"})
}))
defer srv.Close()
c := NewClient(srv.URL, "bad-key", "unarr-test")
_, err := c.WaitForWake(context.Background())
if err == nil {
t.Fatal("expected error for 401 response")
}
}
func TestWaitForWake_RespectsContextCancellation(t *testing.T) {
// Server blocks until client disconnects
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
<-r.Context().Done()
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel immediately
c := NewClient(srv.URL, "test-key", "unarr-test")
_, err := c.WaitForWake(ctx)
if err == nil {
t.Fatal("expected error when context is cancelled")
}
}
func TestWaitForWake_SimulatesLongPoll(t *testing.T) {
// Server holds connection briefly then responds with wake=true
ready := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
select {
case <-ready:
case <-r.Context().Done():
return
}
json.NewEncoder(w).Encode(map[string]bool{"wake": true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resultCh := make(chan bool, 1)
go func() {
woke, err := c.WaitForWake(context.Background())
if err != nil {
t.Errorf("WaitForWake failed: %v", err)
}
resultCh <- woke
}()
// Simulate server waking after 50ms
time.Sleep(50 * time.Millisecond)
close(ready)
select {
case woke := <-resultCh:
if !woke {
t.Error("expected wake=true")
}
case <-time.After(2 * time.Second):
t.Fatal("WaitForWake did not return in time")
} }
} }

View file

@ -2,95 +2,173 @@ package agent
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
"os/exec"
"runtime" "runtime"
"strings"
"sync/atomic"
"time" "time"
"github.com/torrentclaw/unarr/internal/upgrade"
) )
// DaemonConfig holds daemon runtime settings. // DaemonConfig holds daemon runtime settings.
type DaemonConfig struct { type DaemonConfig struct {
AgentID string AgentID string
AgentName string AgentName string
Version string Version string
DownloadDir string DownloadDir string
PollInterval time.Duration StreamPort int // port for the HTTP stream server
HeartbeatInterval time.Duration LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
CanDelete bool // library.allow_delete is enabled
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 the main loop: register, heartbeat, poll tasks. // Daemon manages agent registration and the sync loop.
type Daemon struct { type Daemon struct {
cfg DaemonConfig cfg DaemonConfig
transport Transport client *Client
sync *SyncClient
state *LocalState
// Callbacks // Callbacks — set by cmd/daemon.go before calling Run.
OnTasksClaimed func(tasks []Task) OnTasksClaimed func(tasks []Task)
OnStreamRequested func(req StreamRequest) OnStreamRequested func(req StreamRequest)
OnControlAction func(action, taskID string) OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
// State // State
User UserInfo User UserInfo
Features FeatureFlags Features FeatureFlags
Info AgentInfo Info AgentInfo
State DaemonState State DaemonState
heartbeatFailures int
lastNotifiedVersion string lastNotifiedVersion string
// Callbacks for state tracking (set by cmd/daemon.go) // Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
GetActiveCount func() int // into DaemonState on every write so external tools (`unarr vpn status`) see it.
GetCleanableBytes func() int64 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 tracks whether a user is viewing download progress in the web UI.
// When false, the progress reporter skips detailed updates (only sends final states). Watching atomic.Bool
Watching bool
// Exposed tickers for hot-reload // ScanNow triggers an immediate library scan.
PollTicker *time.Ticker ScanNow chan struct{}
HeartbeatTicker *time.Ticker
// pollNow triggers an immediate poll (e.g. on resume)
pollNow chan struct{}
} }
// NewDaemon creates a daemon with the given transport. // NewDaemon creates a daemon with an HTTP client for sync-based communication.
// Use NewHTTPTransport for HTTP-only, or NewHybridTransport for WS+HTTP. func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
func NewDaemon(cfg DaemonConfig, transport Transport) *Daemon { state := NewLocalState()
if cfg.PollInterval == 0 {
cfg.PollInterval = 30 * time.Second
}
if cfg.HeartbeatInterval == 0 {
cfg.HeartbeatInterval = 30 * time.Second
}
return &Daemon{ return &Daemon{
cfg: cfg, cfg: cfg,
transport: transport, client: client,
pollNow: make(chan struct{}, 1), state: state,
sync: NewSyncClient(client, cfg, state),
ScanNow: make(chan struct{}, 1),
} }
} }
// Transport returns the configured transport. // SyncClient returns the sync client for external wiring.
func (d *Daemon) Transport() Transport { return d.transport } 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
d.sync.cfg.StreamPort = port
}
// Register registers the agent and fetches user info + features. // Register registers the agent and fetches user info + features.
// Retries with exponential backoff on transient errors (429, 5xx, network).
func (d *Daemon) Register(ctx context.Context) error { func (d *Daemon) Register(ctx context.Context) error {
req := RegisterRequest{ req := RegisterRequest{
AgentID: d.cfg.AgentID, AgentID: d.cfg.AgentID,
Name: d.cfg.AgentName, Name: d.cfg.AgentName,
OS: runtime.GOOS, OS: runtime.GOOS,
Arch: runtime.GOARCH, Arch: runtime.GOARCH,
Version: d.cfg.Version, Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir, DownloadDir: d.cfg.DownloadDir,
StreamPort: d.cfg.StreamPort,
LanIP: d.cfg.LanIP,
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 { if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free req.DiskFreeBytes = free
req.DiskTotalBytes = total req.DiskTotalBytes = total
} }
resp, err := d.transport.Register(ctx, req) const maxRetries = 5
backoff := 5 * time.Second
var resp *RegisterResponse
var err error
for attempt := range maxRetries {
resp, err = d.client.Register(ctx, req)
if err == nil {
break
}
if !isTransientError(err) {
return fmt.Errorf("register: %w", err)
}
log.Printf("Register failed (attempt %d/%d): %v - retrying in %v", attempt+1, maxRetries, err, backoff)
timer := time.NewTimer(backoff)
select {
case <-ctx.Done():
timer.Stop()
return fmt.Errorf("register: %w", ctx.Err())
case <-timer.C:
}
backoff = min(backoff*2, 60*time.Second)
}
if err != nil { if err != nil {
return fmt.Errorf("register: %w", err) return fmt.Errorf("register: %w (after %d retries)", err, maxRetries)
} }
d.User = resp.User d.User = resp.User
@ -110,13 +188,18 @@ func (d *Daemon) Register(ctx context.Context) error {
PID: os.Getpid(), PID: os.Getpid(),
StartedAt: now, StartedAt: now,
MethodStats: make(map[string]int), MethodStats: make(map[string]int),
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
} }
WriteState(&d.State) WriteState(&d.State)
return nil return nil
} }
// Run starts the main daemon loop. Blocks until ctx is cancelled. // Run registers the agent and starts the sync loop.
// Blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error { func (d *Daemon) Run(ctx context.Context) error {
// Register // Register
if err := d.Register(ctx); err != nil { if err := d.Register(ctx); err != nil {
@ -125,137 +208,93 @@ 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("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) log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
log.Printf("Polling every %s, heartbeat every %s", d.cfg.PollInterval, d.cfg.HeartbeatInterval)
d.HeartbeatTicker = time.NewTicker(d.cfg.HeartbeatInterval) // Usenet needs par2 (segment repair) + an extractor (RAR/7z) on the host.
defer d.HeartbeatTicker.Stop() // 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.")
}
}
d.PollTicker = time.NewTicker(d.cfg.PollInterval) // Wire sync callbacks
defer d.PollTicker.Stop() d.sync.OnNewTasks = func(tasks []Task) {
if d.OnTasksClaimed != nil {
heartbeatTicker := d.HeartbeatTicker d.OnTasksClaimed(tasks)
pollTicker := d.PollTicker }
}
// Initial poll immediately d.sync.OnControl = func(action, taskID string, deleteFiles bool) {
d.poll(ctx) if d.OnControlAction != nil {
d.OnControlAction(action, taskID, deleteFiles)
eventsCh := d.transport.Events() }
}
for { d.sync.OnStreamRequest = func(req StreamRequest) {
if d.OnStreamRequested != nil {
d.OnStreamRequested(req)
}
}
d.sync.OnStreamSession = func(sess StreamSession) {
if d.OnStreamSession != nil {
d.OnStreamSession(sess)
}
}
d.sync.OnUpgrade = func(version string) {
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")
select { select {
case <-ctx.Done(): case d.ScanNow <- struct{}{}:
log.Println("Daemon shutting down...") default:
d.deregister()
return nil
case event := <-eventsCh:
d.handleEvent(event)
case <-heartbeatTicker.C:
d.heartbeat(ctx)
case <-pollTicker.C:
// Only poll in HTTP mode — WS mode receives tasks via Events
if d.transport.Mode() == "http" {
d.poll(ctx)
}
case <-d.pollNow:
d.poll(ctx)
} }
} }
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 {
d.State.ActiveTasks = d.GetActiveCount()
}
WriteState(&d.State)
}
// Start sync loop (blocks)
return d.sync.Run(ctx)
} }
func (d *Daemon) heartbeat(ctx context.Context) { // TriggerSync requests an immediate sync cycle.
req := HeartbeatRequest{ func (d *Daemon) TriggerSync() {
AgentID: d.cfg.AgentID, d.sync.TriggerSync()
Name: d.cfg.AgentName,
Version: d.cfg.Version,
OS: runtime.GOOS,
DownloadDir: d.cfg.DownloadDir,
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
resp, err := d.transport.SendHeartbeat(ctx, req)
if err != nil {
d.heartbeatFailures++
if d.heartbeatFailures >= 5 && d.heartbeatFailures%5 == 0 {
log.Printf("CRITICAL: %d consecutive heartbeat failures — server may be unreachable", d.heartbeatFailures)
} else {
log.Printf("Heartbeat failed: %v", err)
}
return
}
if d.heartbeatFailures > 0 {
log.Printf("Heartbeat recovered after %d failures", d.heartbeatFailures)
d.heartbeatFailures = 0
}
// Update watching flag and state file
d.Watching = resp.Watching
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
d.State.ActiveTasks = d.GetActiveCount()
}
WriteState(&d.State)
// Log once per version when server suggests an upgrade
if resp.Upgrade != nil && resp.Upgrade.Version != "" && resp.Upgrade.Version != d.lastNotifiedVersion {
d.lastNotifiedVersion = resp.Upgrade.Version
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", resp.Upgrade.Version)
}
} }
// handleEvent processes a server-initiated event from the WebSocket transport. // Deregister notifies the server of graceful shutdown.
func (d *Daemon) handleEvent(event ServerEvent) { func (d *Daemon) Deregister() {
switch event.Type {
case "tasks":
if event.Tasks != nil && len(event.Tasks.Tasks) > 0 {
log.Printf("Received %d task(s) via WebSocket", len(event.Tasks.Tasks))
if d.OnTasksClaimed != nil {
d.OnTasksClaimed(event.Tasks.Tasks)
}
}
if event.Tasks != nil && d.OnStreamRequested != nil {
for _, sr := range event.Tasks.StreamRequests {
d.OnStreamRequested(sr)
}
}
case "upgrade":
if event.Upgrade != nil && event.Upgrade.Version != "" && event.Upgrade.Version != d.lastNotifiedVersion {
d.lastNotifiedVersion = event.Upgrade.Version
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", event.Upgrade.Version)
}
case "control":
if event.Control != nil && d.OnControlAction != nil {
log.Printf("Control action via WebSocket: %s task %s", event.Control.Action, event.Control.TaskID)
d.OnControlAction(event.Control.Action, event.Control.TaskID)
}
case "disconnected":
log.Println("WebSocket disconnected, switching to HTTP polling")
}
}
// TriggerPoll requests an immediate task poll cycle.
// Used when a resume event is received to pick up re-pending tasks faster.
func (d *Daemon) TriggerPoll() {
select {
case d.pollNow <- struct{}{}:
default: // already pending
}
}
func (d *Daemon) deregister() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
err := d.transport.Deregister(ctx, d.cfg.AgentID) if err := d.client.Deregister(ctx, d.cfg.AgentID); err != nil {
if err != nil {
log.Printf("Deregister failed: %v", err) log.Printf("Deregister failed: %v", err)
} else { } else {
log.Println("Agent deregistered") log.Println("Agent deregistered")
@ -263,26 +302,81 @@ func (d *Daemon) deregister() {
RemoveState() RemoveState()
} }
func (d *Daemon) poll(ctx context.Context) { // applyAutoUpgrade downloads the target version and exits so the service
resp, err := d.transport.ClaimTasks(ctx, d.cfg.AgentID) // supervisor (systemd Restart=always on Linux) respawns on the new binary.
if err != nil { // Triggered by the server's upgrade signal — opt-in flag set by the user from
log.Printf("Poll failed: %v", err) // 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 return
} }
d.Info.LastPollAt = time.Now() upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
if len(resp.Tasks) > 0 { OnProgress: func(msg string) {
log.Printf("Claimed %d task(s)", len(resp.Tasks)) log.Printf("[upgrade] %s", msg)
if d.OnTasksClaimed != nil { },
d.OnTasksClaimed(resp.Tasks)
}
} }
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
// Handle stream requests for completed downloads defer cancel()
if d.OnStreamRequested != nil { result := upgrader.Execute(ctx, targetVersion)
for _, sr := range resp.StreamRequests { if !result.Success {
d.OnStreamRequested(sr) 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 {
return false
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
return httpErr.StatusCode == 429 || httpErr.StatusCode >= 500
}
lower := strings.ToLower(err.Error())
for _, keyword := range []string{"connection refused", "no such host", "timeout", "request failed"} {
if strings.Contains(lower, keyword) {
return true
}
}
return false
} }

25
internal/agent/disk.go Normal file
View file

@ -0,0 +1,25 @@
package agent
import (
"io/fs"
"path/filepath"
)
// DirSize returns the total size in bytes of all files under dir.
func DirSize(dir string) (int64, error) {
var size int64
err := filepath.WalkDir(dir, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries
}
if !d.IsDir() {
info, err := d.Info()
if err != nil {
return nil
}
size += info.Size()
}
return nil
})
return size, err
}

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

@ -2,6 +2,8 @@ package agent
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -9,6 +11,13 @@ import (
"github.com/torrentclaw/unarr/internal/config" "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. // DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct { type DaemonState struct {
AgentID string `json:"agentId"` AgentID string `json:"agentId"`
@ -22,6 +31,18 @@ type DaemonState struct {
FailedCount int `json:"failedCount"` FailedCount int `json:"failedCount"`
TotalDownloaded int64 `json:"totalDownloaded"` TotalDownloaded int64 `json:"totalDownloaded"`
MethodStats map[string]int `json:"methodStats,omitempty"` 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. // stateFilePathFn is overridable for testing.
@ -45,25 +66,43 @@ func WriteState(state *DaemonState) {
return 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" tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil { if err := os.WriteFile(tmp, data, 0o600); err != nil {
return return
} }
os.Rename(tmp, path) 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 { 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()) data, err := os.ReadFile(StateFilePath())
if err != nil { if err != nil {
return nil if errors.Is(err, os.ErrNotExist) {
return nil, ErrDaemonNotRunning
}
return nil, err
} }
var state DaemonState var state DaemonState
if json.Unmarshal(data, &state) != nil { if err := json.Unmarshal(data, &state); err != nil {
return 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). // RemoveState deletes the state file (called on clean shutdown).

View file

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"errors"
"os" "os"
"path/filepath" "path/filepath"
"testing" "testing"
@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state) 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")
}
}

311
internal/agent/sync.go Normal file
View file

@ -0,0 +1,311 @@
package agent
import (
"context"
"log"
"runtime"
"sync"
"sync/atomic"
"time"
)
const (
// SyncIntervalWatching is the sync interval when someone is viewing the web UI.
SyncIntervalWatching = 3 * time.Second
// SyncIntervalIdle is the sync interval when nobody is watching.
// Keep this short enough to pick up stream requests quickly without hammering the server.
SyncIntervalIdle = 10 * time.Second
)
// SyncClient handles bidirectional state synchronization between the CLI and server.
// It sends the CLI's full execution state and receives all pending server actions
// in a single HTTP round-trip, at an adaptive interval.
type SyncClient struct {
client *Client
cfg DaemonConfig
state *LocalState
// Callbacks — set by the daemon before calling Run.
OnNewTasks func(tasks []Task)
OnControl func(action, taskID string, deleteFiles bool)
OnStreamRequest func(req StreamRequest)
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
// SyncNow triggers an immediate sync (e.g., on task completion).
SyncNow chan struct{}
watching atomic.Bool
interval atomic.Int64 // stored as nanoseconds
// pendingDeleteConfirmed holds item IDs to report as deleted in the next sync.
pendingDeleteMu sync.Mutex
pendingDeleteConfirmed []int
// deleteInFlight tracks item IDs currently being processed or awaiting confirmation.
// Prevents the same file from being passed to OnDeleteFiles multiple times.
deleteInFlight map[int]struct{}
}
// NewSyncClient creates a sync client.
func NewSyncClient(client *Client, cfg DaemonConfig, state *LocalState) *SyncClient {
sc := &SyncClient{
client: client,
cfg: cfg,
state: state,
SyncNow: make(chan struct{}, 1),
}
sc.interval.Store(int64(SyncIntervalIdle))
return sc
}
// Watching returns whether someone is viewing the web UI.
func (sc *SyncClient) Watching() bool {
return sc.watching.Load()
}
// TriggerSync requests an immediate sync cycle.
func (sc *SyncClient) TriggerSync() {
select {
case sc.SyncNow <- struct{}{}:
default:
}
}
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
func (sc *SyncClient) Run(ctx context.Context) error {
// Start wake listener in background — triggers immediate syncs on demand.
go sc.runWakeListener(ctx)
// Initial sync immediately
sc.doSync(ctx)
ticker := time.NewTicker(sc.currentInterval())
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// Final sync to report latest state
finalCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
sc.doSync(finalCtx)
return nil
case <-ticker.C:
sc.doSync(ctx)
ticker.Reset(sc.currentInterval())
case <-sc.SyncNow:
sc.doSync(ctx)
ticker.Reset(sc.currentInterval())
}
}
}
func (sc *SyncClient) currentInterval() time.Duration {
return time.Duration(sc.interval.Load())
}
func (sc *SyncClient) doSync(ctx context.Context) {
req := sc.buildRequest()
resp, err := sc.client.Sync(ctx, req)
if err != nil {
if ctx.Err() == nil {
log.Printf("sync failed: %v", err)
}
return
}
sc.processResponse(resp)
sc.adjustInterval(resp.Watching)
if sc.OnSyncSuccess != nil {
sc.OnSyncSuccess()
}
}
func (sc *SyncClient) buildRequest() SyncRequest {
req := SyncRequest{
AgentID: sc.cfg.AgentID,
Name: sc.cfg.AgentName,
Version: sc.cfg.Version,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
DownloadDir: sc.cfg.DownloadDir,
StreamPort: sc.cfg.StreamPort,
LanIP: sc.cfg.LanIP,
TailscaleIP: sc.cfg.TailscaleIP,
CanDelete: sc.cfg.CanDelete,
}
if sc.GetTaskStates != nil {
req.Tasks = sc.GetTaskStates()
} else {
req.Tasks = sc.state.Snapshot()
}
if free, total, err := DiskInfo(sc.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
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.
sc.pendingDeleteMu.Lock()
if len(sc.pendingDeleteConfirmed) > 0 {
req.DeleteConfirmed = sc.pendingDeleteConfirmed
for _, id := range sc.pendingDeleteConfirmed {
delete(sc.deleteInFlight, id)
}
sc.pendingDeleteConfirmed = nil
}
sc.pendingDeleteMu.Unlock()
return req
}
func (sc *SyncClient) processResponse(resp *SyncResponse) {
// New tasks
if len(resp.NewTasks) > 0 && sc.OnNewTasks != nil {
log.Printf("sync: received %d new task(s)", len(resp.NewTasks))
sc.OnNewTasks(resp.NewTasks)
}
// Control signals
for _, ctrl := range resp.Controls {
log.Printf("sync: control %s on task %s", ctrl.Action, ShortID(ctrl.TaskID))
if sc.OnControl != nil {
sc.OnControl(ctrl.Action, ctrl.TaskID, ctrl.DeleteFiles)
}
}
// Stream requests
for _, sr := range resp.StreamRequests {
if sc.OnStreamRequest != nil {
sc.OnStreamRequest(sr)
}
}
// HLS streaming sessions.
for _, ws := range resp.StreamSessions {
if sc.OnStreamSession != nil {
sc.OnStreamSession(ws)
}
}
// Upgrade
if resp.Upgrade != nil && resp.Upgrade.Version != "" && sc.OnUpgrade != nil {
sc.OnUpgrade(resp.Upgrade.Version)
}
// Scan
if resp.Scan && sc.OnScan != nil {
sc.OnScan()
}
// File deletions requested by the server — deduplicate against in-flight items
if len(resp.FilesToDelete) > 0 && sc.OnDeleteFiles != nil {
sc.pendingDeleteMu.Lock()
if sc.deleteInFlight == nil {
sc.deleteInFlight = make(map[int]struct{})
}
var newItems []LibraryDeleteRequest
for _, item := range resp.FilesToDelete {
if _, inFlight := sc.deleteInFlight[item.ItemID]; !inFlight {
newItems = append(newItems, item)
sc.deleteInFlight[item.ItemID] = struct{}{}
}
}
sc.pendingDeleteMu.Unlock()
if len(newItems) > 0 {
// Run deletions off the sync goroutine — disk I/O must not block the
// next sync tick. Confirmations are picked up on the next regular cycle.
go func(items []LibraryDeleteRequest) {
confirmed := sc.OnDeleteFiles(items)
if len(confirmed) > 0 {
sc.pendingDeleteMu.Lock()
sc.pendingDeleteConfirmed = append(sc.pendingDeleteConfirmed, confirmed...)
sc.pendingDeleteMu.Unlock()
}
}(newItems)
}
}
}
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.
// When the server resolves it with wake=true (e.g., a stream was requested),
// it triggers an immediate sync so the CLI acts in <100ms instead of waiting
// for the next scheduled interval. Reconnects immediately after each response
// so coverage is continuous. Runs until ctx is cancelled.
func (sc *SyncClient) runWakeListener(ctx context.Context) {
const retryDelay = 2 * time.Second
for {
if ctx.Err() != nil {
return
}
woke, err := sc.client.WaitForWake(ctx)
if ctx.Err() != nil {
return
}
if err != nil {
log.Printf("wake listener: %v (retrying in %s)", err, retryDelay)
select {
case <-ctx.Done():
return
case <-time.After(retryDelay):
}
continue
}
if woke {
log.Printf("wake signal received — syncing immediately")
sc.TriggerSync()
}
// On timeout (woke=false) or after a wake, reconnect immediately.
}
}
func (sc *SyncClient) adjustInterval(watching bool) {
prev := sc.watching.Load()
sc.watching.Store(watching)
var newInterval time.Duration
if watching {
newInterval = SyncIntervalWatching
} else {
newInterval = SyncIntervalIdle
}
if sc.interval.Swap(int64(newInterval)) != int64(newInterval) {
log.Printf("sync: interval=%s (watching=%v)", newInterval, watching)
}
// Trigger an immediate sync when entering watching mode so stream requests
// are picked up right away without waiting for the next scheduled interval.
if watching && !prev {
sc.TriggerSync()
}
if prev != watching && sc.OnWatchingChange != nil {
sc.OnWatchingChange(watching)
}
}

542
internal/agent/sync_test.go Normal file
View file

@ -0,0 +1,542 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func newTestSyncClient(url string) (*SyncClient, *Client) {
client := NewClient(url, "test-key", "test-agent/1.0")
cfg := DaemonConfig{
AgentID: "test-agent",
AgentName: "Test",
Version: "1.0.0",
DownloadDir: "/tmp/downloads",
}
state := NewLocalState()
sc := NewSyncClient(client, cfg, state)
return sc, client
}
func TestSyncClient_NewDefaults(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
if sc.Watching() {
t.Error("should not be watching initially")
}
if sc.currentInterval() != SyncIntervalIdle {
t.Errorf("expected idle interval %v, got %v", SyncIntervalIdle, sc.currentInterval())
}
}
func TestSyncClient_AdjustInterval_Watching(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
sc.adjustInterval(true)
if sc.currentInterval() != SyncIntervalWatching {
t.Errorf("expected watching interval %v, got %v", SyncIntervalWatching, sc.currentInterval())
}
if !sc.Watching() {
t.Error("expected watching=true")
}
}
func TestSyncClient_AdjustInterval_NotWatching(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
// First set watching, then unset
sc.adjustInterval(true)
sc.adjustInterval(false)
if sc.currentInterval() != SyncIntervalIdle {
t.Errorf("expected idle interval %v, got %v", SyncIntervalIdle, sc.currentInterval())
}
if sc.Watching() {
t.Error("expected watching=false")
}
}
func TestSyncClient_AdjustInterval_CallsOnWatchingChange(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var changes []bool
sc.OnWatchingChange = func(w bool) { changes = append(changes, w) }
sc.adjustInterval(true)
sc.adjustInterval(true) // no change
sc.adjustInterval(false) // change
if len(changes) != 2 {
t.Fatalf("expected 2 changes, got %d: %v", len(changes), changes)
}
if !changes[0] {
t.Error("first change should be true")
}
if changes[1] {
t.Error("second change should be false")
}
}
func TestSyncClient_TriggerSync_NonBlocking(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
// Fill the channel
sc.TriggerSync()
// Should not block
sc.TriggerSync()
sc.TriggerSync()
// Drain
select {
case <-sc.SyncNow:
default:
t.Error("expected a sync trigger in channel")
}
}
func TestSyncClient_ProcessResponse_NewTasks(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var received []Task
sc.OnNewTasks = func(tasks []Task) { received = tasks }
sc.processResponse(&SyncResponse{
NewTasks: []Task{
{ID: "t1", Title: "Movie 1", InfoHash: "abc"},
{ID: "t2", Title: "Movie 2", InfoHash: "def"},
},
})
if len(received) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(received))
}
if received[0].Title != "Movie 1" {
t.Errorf("expected Movie 1, got %s", received[0].Title)
}
}
func TestSyncClient_ProcessResponse_NoTasks(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var called bool
sc.OnNewTasks = func(tasks []Task) { called = true }
sc.processResponse(&SyncResponse{NewTasks: nil})
if called {
t.Error("OnNewTasks should not be called with empty tasks")
}
}
func TestSyncClient_ProcessResponse_Controls(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var actions []string
var taskIDs []string
sc.OnControl = func(action, taskID string, deleteFiles bool) {
actions = append(actions, action)
taskIDs = append(taskIDs, taskID)
}
sc.processResponse(&SyncResponse{
Controls: []ControlAction{
{Action: "cancel", TaskID: "task-1234-5678"},
{Action: "pause", TaskID: "task-abcd-efgh"},
},
})
if len(actions) != 2 {
t.Fatalf("expected 2 controls, got %d", len(actions))
}
if actions[0] != "cancel" {
t.Errorf("expected cancel, got %s", actions[0])
}
if actions[1] != "pause" {
t.Errorf("expected pause, got %s", actions[1])
}
}
func TestSyncClient_ProcessResponse_Upgrade(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var version string
sc.OnUpgrade = func(v string) { version = v }
sc.processResponse(&SyncResponse{
Upgrade: &UpgradeSignal{Version: "2.0.0"},
})
if version != "2.0.0" {
t.Errorf("expected 2.0.0, got %s", version)
}
}
func TestSyncClient_ProcessResponse_UpgradeEmpty(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var called bool
sc.OnUpgrade = func(v string) { called = true }
sc.processResponse(&SyncResponse{
Upgrade: &UpgradeSignal{Version: ""},
})
if called {
t.Error("OnUpgrade should not be called with empty version")
}
}
func TestSyncClient_ProcessResponse_Scan(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var called bool
sc.OnScan = func() { called = true }
sc.processResponse(&SyncResponse{Scan: true})
if !called {
t.Error("OnScan should have been called")
}
}
func TestSyncClient_ProcessResponse_StreamRequests(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
var received []StreamRequest
sc.OnStreamRequest = func(sr StreamRequest) { received = append(received, sr) }
sc.processResponse(&SyncResponse{
StreamRequests: []StreamRequest{
{TaskID: "t1", FilePath: "/tmp/movie.mkv"},
},
})
if len(received) != 1 {
t.Fatalf("expected 1 stream request, got %d", len(received))
}
if received[0].FilePath != "/tmp/movie.mkv" {
t.Errorf("expected /tmp/movie.mkv, got %s", received[0].FilePath)
}
}
func TestSyncClient_BuildRequest_WithGetTaskStates(t *testing.T) {
sc, _ := newTestSyncClient("http://localhost")
sc.GetTaskStates = func() []TaskState {
return []TaskState{
{TaskID: "t1", Status: "downloading", Progress: 50},
}
}
sc.GetFreeSlots = func() int { return 2 }
req := sc.buildRequest()
if req.AgentID != "test-agent" {
t.Errorf("expected test-agent, got %s", req.AgentID)
}
if len(req.Tasks) != 1 {
t.Fatalf("expected 1 task, got %d", len(req.Tasks))
}
if req.Tasks[0].Progress != 50 {
t.Errorf("expected progress 50, got %d", req.Tasks[0].Progress)
}
if req.FreeSlots != 2 {
t.Errorf("expected 2 free slots, got %d", req.FreeSlots)
}
}
func TestSyncClient_BuildRequest_FallbackToState(t *testing.T) {
client := NewClient("http://localhost", "key", "ua")
state := NewLocalState()
state.Update(TaskState{TaskID: "t1", Status: "completed", Progress: 100})
sc := NewSyncClient(client, DaemonConfig{AgentID: "a1", Version: "1.0"}, state)
// GetTaskStates is nil — should fall back to state.Snapshot()
req := sc.buildRequest()
if len(req.Tasks) != 1 {
t.Fatalf("expected 1 task from state fallback, got %d", len(req.Tasks))
}
}
func TestSyncClient_DoSync_Success(t *testing.T) {
var syncCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
syncCount.Add(1)
json.NewEncoder(w).Encode(SyncResponse{
Watching: true,
NewTasks: []Task{{ID: "t1", Title: "Test Movie", InfoHash: "abc"}},
})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
var tasksReceived []Task
sc.OnNewTasks = func(tasks []Task) { tasksReceived = tasks }
sc.doSync(context.Background())
if syncCount.Load() != 1 {
t.Errorf("expected 1 sync call, got %d", syncCount.Load())
}
if len(tasksReceived) != 1 {
t.Fatalf("expected 1 task, got %d", len(tasksReceived))
}
if !sc.Watching() {
t.Error("expected watching=true after sync")
}
if sc.currentInterval() != SyncIntervalWatching {
t.Errorf("expected watching interval after sync")
}
}
func TestSyncClient_DoSync_Error(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
// Should not panic on error
sc.doSync(context.Background())
}
func TestSyncClient_Run_CancelStopsLoop(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
err := sc.Run(ctx)
if err != nil {
t.Errorf("expected nil error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// runWakeListener tests
// ---------------------------------------------------------------------------
func TestRunWakeListener_TriggersSyncOnWake(t *testing.T) {
// Server responds immediately with wake=true on the first call
var wakeCallCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/internal/agent/wake" {
wakeCallCount.Add(1)
json.NewEncoder(w).Encode(map[string]bool{"wake": true})
return
}
// sync endpoint — just respond OK
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
go sc.runWakeListener(ctx)
// Give the listener time to receive the wake and call TriggerSync
time.Sleep(200 * time.Millisecond)
cancel()
if wakeCallCount.Load() < 1 {
t.Error("expected at least one wake request")
}
// TriggerSync puts something in the buffered channel
select {
case <-sc.SyncNow:
// good — listener triggered a sync
default:
// channel may have been drained by Run (not running here) — check count
// The important thing is that wakeCallCount > 0 (request was made)
}
}
func TestRunWakeListener_ReconnectsAfterTimeout(t *testing.T) {
// Server returns wake=false (timeout) then wake=true on reconnect
callCount := atomic.Int32{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/wake" {
json.NewEncoder(w).Encode(SyncResponse{})
return
}
n := callCount.Add(1)
if n == 1 {
// First call: timeout
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
} else {
// Second call: wake
json.NewEncoder(w).Encode(map[string]bool{"wake": true})
}
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go sc.runWakeListener(ctx)
// Wait for at least 2 wake calls (reconnect after timeout)
deadline := time.Now().Add(1500 * time.Millisecond)
for time.Now().Before(deadline) {
if callCount.Load() >= 2 {
break
}
time.Sleep(20 * time.Millisecond)
}
if callCount.Load() < 2 {
t.Errorf("expected at least 2 wake requests (reconnect after timeout), got %d", callCount.Load())
}
}
func TestRunWakeListener_RetriesAfterNetworkError(t *testing.T) {
// Server that refuses connections initially, then starts accepting
callCount := atomic.Int32{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/wake" {
json.NewEncoder(w).Encode(SyncResponse{})
return
}
callCount.Add(1)
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
}))
defer srv.Close()
// Use a bad URL first, then switch — we can't easily switch URL, so
// test with a server that always errors (closed connection) via a custom transport
badClient := NewClient("http://127.0.0.1:1", "test-key", "unarr-test")
cfg := DaemonConfig{AgentID: "test-agent", Version: "1.0.0", DownloadDir: "/tmp"}
state := NewLocalState()
sc := NewSyncClient(badClient, cfg, state)
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
// Should not panic — just log errors and retry
done := make(chan struct{})
go func() {
sc.runWakeListener(ctx)
close(done)
}()
select {
case <-done:
// Good — listener exited when ctx was cancelled
case <-time.After(2 * time.Second):
t.Error("runWakeListener did not exit after context cancellation")
}
}
func TestRunWakeListener_StopsOnContextCancel(t *testing.T) {
// Server blocks until client disconnects
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/internal/agent/wake" {
<-r.Context().Done()
return
}
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
sc.runWakeListener(ctx)
close(done)
}()
// Let it connect and block
time.Sleep(50 * time.Millisecond)
cancel()
select {
case <-done:
// Good
case <-time.After(2 * time.Second):
t.Error("runWakeListener did not stop when context was cancelled")
}
}
func TestRunWakeListener_DoesNotTriggerSyncOnTimeout(t *testing.T) {
// Server always returns wake=false — SyncNow channel should stay empty
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/internal/agent/wake" {
json.NewEncoder(w).Encode(map[string]bool{"wake": false})
return
}
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
go sc.runWakeListener(ctx)
<-ctx.Done()
// SyncNow should be empty (no wake triggered)
select {
case <-sc.SyncNow:
t.Error("expected no sync trigger on timeout response")
default:
// Good
}
}
func TestSyncClient_Run_ImmediateSyncOnTrigger(t *testing.T) {
var syncCount atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
syncCount.Add(1)
json.NewEncoder(w).Encode(SyncResponse{})
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
// Set interval to something long so only triggers cause syncs
sc.interval.Store(int64(10 * time.Second))
ctx, cancel := context.WithCancel(context.Background())
go func() {
// Wait for initial sync, then trigger 2 more
time.Sleep(50 * time.Millisecond)
sc.TriggerSync()
time.Sleep(50 * time.Millisecond)
sc.TriggerSync()
time.Sleep(50 * time.Millisecond)
cancel()
}()
sc.Run(ctx)
// Initial sync (1) + 2 triggers + final sync = 4
count := syncCount.Load()
if count < 3 {
t.Errorf("expected at least 3 syncs (initial + 2 triggers), got %d", count)
}
}

136
internal/agent/taskstate.go Normal file
View file

@ -0,0 +1,136 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/torrentclaw/unarr/internal/config"
)
// TaskState represents the execution state of a single download task.
// Written by the Task Engine, read by the Sync goroutine.
type TaskState struct {
TaskID string `json:"taskId"`
Status string `json:"status"` // resolving, downloading, verifying, organizing, completed, failed
Progress int `json:"progress"`
DownloadedBytes int64 `json:"downloadedBytes,omitempty"`
TotalBytes int64 `json:"totalBytes,omitempty"`
SpeedBps int64 `json:"speedBps,omitempty"`
ETA int `json:"eta,omitempty"`
ResolvedMethod string `json:"resolvedMethod,omitempty"`
FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"`
StreamURL string `json:"streamUrl,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
UpdatedAt int64 `json:"updatedAt"`
}
// LocalState holds the CLI's local execution state (tasks.json).
// This is the CLI's source of truth for what it's doing right now.
type LocalState struct {
mu sync.RWMutex
tasks map[string]*TaskState
}
// NewLocalState creates an empty local state.
func NewLocalState() *LocalState {
return &LocalState{
tasks: make(map[string]*TaskState),
}
}
// Update adds or updates a task in local state.
func (s *LocalState) Update(ts TaskState) {
s.mu.Lock()
defer s.mu.Unlock()
ts.UpdatedAt = time.Now().Unix()
copied := ts
s.tasks[ts.TaskID] = &copied
}
// Remove removes a task from local state.
func (s *LocalState) Remove(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.tasks, taskID)
}
// Snapshot returns a copy of all current task states.
func (s *LocalState) Snapshot() []TaskState {
s.mu.RLock()
defer s.mu.RUnlock()
result := make([]TaskState, 0, len(s.tasks))
for _, ts := range s.tasks {
result = append(result, *ts)
}
return result
}
// TaskStateFromUpdate converts a StatusUpdate into a TaskState.
func TaskStateFromUpdate(u StatusUpdate) TaskState {
return TaskState{
TaskID: u.TaskID,
Status: u.Status,
Progress: u.Progress,
DownloadedBytes: u.DownloadedBytes,
TotalBytes: u.TotalBytes,
SpeedBps: u.SpeedBps,
ETA: u.ETA,
ResolvedMethod: u.ResolvedMethod,
FileName: u.FileName,
FilePath: u.FilePath,
StreamURL: u.StreamURL,
ErrorMessage: u.ErrorMessage,
}
}
// ShortID returns the first 8 characters of an ID, or the full ID if shorter.
func ShortID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
// taskStateFilePathFn is overridable for testing.
var taskStateFilePathFn = func() string {
return filepath.Join(config.DataDir(), "tasks.json")
}
// WriteToDisk persists local state to disk atomically (best-effort).
func (s *LocalState) WriteToDisk() {
tasks := s.Snapshot()
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return
}
path := taskStateFilePathFn()
dir := filepath.Dir(path)
os.MkdirAll(dir, 0o755)
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
os.Rename(tmp, path)
}
// ReadFromDisk loads local state from disk. Returns empty state on error.
func (s *LocalState) ReadFromDisk() {
data, err := os.ReadFile(taskStateFilePathFn())
if err != nil {
return
}
var tasks []TaskState
if json.Unmarshal(data, &tasks) != nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.tasks = make(map[string]*TaskState, len(tasks))
for i := range tasks {
s.tasks[tasks[i].TaskID] = &tasks[i]
}
}

View file

@ -0,0 +1,270 @@
package agent
import (
"os"
"path/filepath"
"sync"
"testing"
)
func TestLocalState_UpdateAndSnapshot(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 50})
s.Update(TaskState{TaskID: "t2", Status: "completed", Progress: 100})
snap := s.Snapshot()
if len(snap) != 2 {
t.Fatalf("expected 2 tasks, got %d", len(snap))
}
byID := make(map[string]TaskState, len(snap))
for _, ts := range snap {
byID[ts.TaskID] = ts
}
if byID["t1"].Progress != 50 {
t.Errorf("expected progress 50, got %d", byID["t1"].Progress)
}
if byID["t2"].Status != "completed" {
t.Errorf("expected completed, got %s", byID["t2"].Status)
}
}
func TestLocalState_UpdateOverwrites(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 30})
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 70})
snap := s.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 task, got %d", len(snap))
}
if snap[0].Progress != 70 {
t.Errorf("expected progress 70, got %d", snap[0].Progress)
}
}
func TestLocalState_Remove(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading"})
s.Update(TaskState{TaskID: "t2", Status: "downloading"})
s.Remove("t1")
snap := s.Snapshot()
if len(snap) != 1 {
t.Fatalf("expected 1 task, got %d", len(snap))
}
if snap[0].TaskID != "t2" {
t.Errorf("expected t2, got %s", snap[0].TaskID)
}
}
func TestLocalState_RemoveNonExistent(t *testing.T) {
s := NewLocalState()
s.Remove("nonexistent") // should not panic
}
func TestLocalState_SnapshotIsACopy(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 50})
snap := s.Snapshot()
snap[0].Progress = 999
snap2 := s.Snapshot()
if snap2[0].Progress != 50 {
t.Errorf("snapshot mutation leaked: got progress %d", snap2[0].Progress)
}
}
func TestLocalState_UpdateSetsTimestamp(t *testing.T) {
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading"})
snap := s.Snapshot()
if snap[0].UpdatedAt == 0 {
t.Error("expected non-zero UpdatedAt")
}
}
func TestLocalState_ConcurrentAccess(t *testing.T) {
s := NewLocalState()
var wg sync.WaitGroup
for i := range 100 {
wg.Add(1)
go func(n int) {
defer wg.Done()
taskID := "t" + string(rune('0'+n%10))
s.Update(TaskState{TaskID: taskID, Status: "downloading", Progress: n})
s.Snapshot()
if n%3 == 0 {
s.Remove(taskID)
}
}(i)
}
wg.Wait()
// No race condition = test passes
}
func TestLocalState_WriteToDisk_ReadFromDisk(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
// Override the file path for testing
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return path }
defer func() { taskStateFilePathFn = orig }()
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading", Progress: 45})
s.Update(TaskState{TaskID: "t2", Status: "completed", Progress: 100, FilePath: "/tmp/movie.mkv"})
s.WriteToDisk()
// Verify file exists
if _, err := os.Stat(path); os.IsNotExist(err) {
t.Fatal("tasks.json was not created")
}
// Read into a new LocalState
s2 := NewLocalState()
s2.ReadFromDisk()
snap := s2.Snapshot()
if len(snap) != 2 {
t.Fatalf("expected 2 tasks after read, got %d", len(snap))
}
byID := make(map[string]TaskState, len(snap))
for _, ts := range snap {
byID[ts.TaskID] = ts
}
if byID["t1"].Progress != 45 {
t.Errorf("expected progress 45, got %d", byID["t1"].Progress)
}
if byID["t2"].FilePath != "/tmp/movie.mkv" {
t.Errorf("expected /tmp/movie.mkv, got %s", byID["t2"].FilePath)
}
}
func TestLocalState_ReadFromDisk_CorruptedFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return path }
defer func() { taskStateFilePathFn = orig }()
// Write corrupted JSON
os.WriteFile(path, []byte("{invalid json"), 0o644)
s := NewLocalState()
s.ReadFromDisk() // should not panic
snap := s.Snapshot()
if len(snap) != 0 {
t.Errorf("expected 0 tasks from corrupted file, got %d", len(snap))
}
}
func TestLocalState_ReadFromDisk_FileNotFound(t *testing.T) {
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return "/nonexistent/path/tasks.json" }
defer func() { taskStateFilePathFn = orig }()
s := NewLocalState()
s.ReadFromDisk() // should not panic
snap := s.Snapshot()
if len(snap) != 0 {
t.Errorf("expected 0 tasks, got %d", len(snap))
}
}
func TestLocalState_AtomicWrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tasks.json")
orig := taskStateFilePathFn
taskStateFilePathFn = func() string { return path }
defer func() { taskStateFilePathFn = orig }()
s := NewLocalState()
s.Update(TaskState{TaskID: "t1", Status: "downloading"})
s.WriteToDisk()
// Verify no .tmp file remains
tmpPath := path + ".tmp"
if _, err := os.Stat(tmpPath); !os.IsNotExist(err) {
t.Error("temp file should not exist after write")
}
}
func TestLocalState_EmptySnapshot(t *testing.T) {
s := NewLocalState()
snap := s.Snapshot()
if snap == nil {
t.Error("snapshot should be non-nil empty slice")
}
if len(snap) != 0 {
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

@ -1,50 +0,0 @@
package agent
import "context"
// Transport abstracts the communication protocol between the agent and server.
// Both WebSocket (via CF Durable Object) and HTTP (direct to origin) implement this.
type Transport interface {
// Connect establishes the transport connection.
Connect(ctx context.Context) error
// Close tears down the connection gracefully.
Close() error
// Mode returns the current transport mode ("ws" or "http").
Mode() string
// Register sends agent registration and returns user info + features.
Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error)
// SendHeartbeat sends a periodic keep-alive.
SendHeartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error)
// SendProgress reports download progress for a task.
SendProgress(ctx context.Context, update StatusUpdate) (*StatusResponse, error)
// ClaimTasks polls for new tasks (HTTP mode only; WS receives via Events).
ClaimTasks(ctx context.Context, agentID string) (*TasksResponse, error)
// Deregister notifies the server of graceful shutdown.
Deregister(ctx context.Context, agentID string) error
// Events returns a channel that emits server-initiated events.
// In HTTP mode this channel is never written to (polling handles it).
// In WS mode, tasks/upgrade/control arrive here.
Events() <-chan ServerEvent
}
// ServerEvent represents a server-initiated message received via WebSocket.
type ServerEvent struct {
Type string // "tasks", "upgrade", "control", "disconnected"
Tasks *TasksResponse // populated when Type == "tasks"
Upgrade *UpgradeSignal // populated when Type == "upgrade"
Control *ControlAction // populated when Type == "control"
}
// ControlAction represents a server push for task control.
type ControlAction struct {
Action string `json:"action"` // "pause", "resume", "cancel", "stream"
TaskID string `json:"taskId"`
}

View file

@ -1,285 +0,0 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// TestE2EFullLifecycle tests the full lifecycle:
// connect → auth → receive tasks → send progress → receive control → disconnect → reconnect
func TestE2EFullLifecycle(t *testing.T) {
var mu sync.Mutex
var receivedMessages []map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
var parsed map[string]interface{}
json.Unmarshal(msg, &parsed)
mu.Lock()
receivedMessages = append(receivedMessages, parsed)
mu.Unlock()
msgType, _ := parsed["type"].(string)
switch msgType {
case "auth":
conn.WriteJSON(wsRegisteredMessage{
Type: "registered",
User: UserInfo{Name: "E2E User", Plan: "pro", IsPro: true},
Features: FeatureFlags{Torrent: true, Debrid: true},
})
case "heartbeat":
// No response in WS mode
case "progress":
// Simulate server-side cancel after progress
if progress, ok := parsed["progress"].(float64); ok && progress >= 50 {
conn.WriteJSON(map[string]string{
"type": "control",
"action": "cancel",
"taskId": parsed["taskId"].(string),
})
}
case "upgrade-result":
// Acknowledged
}
}
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "e2e-key", "e2e-agent", "test/1.0")
ctx := context.Background()
// 1. Connect
if err := tr.Connect(ctx); err != nil {
t.Fatalf("Connect: %v", err)
}
defer tr.Close()
// 2. Auth
resp, err := tr.Register(ctx, RegisterRequest{
AgentID: "e2e-agent",
Name: "E2E Test Agent",
Version: "1.0.0",
OS: "linux",
Arch: "amd64",
})
if err != nil {
t.Fatalf("Register: %v", err)
}
if resp.User.Name != "E2E User" {
t.Errorf("expected E2E User, got %s", resp.User.Name)
}
if !resp.Features.Debrid {
t.Error("expected debrid feature")
}
// 3. Send heartbeat
_, err = tr.SendHeartbeat(ctx, HeartbeatRequest{
AgentID: "e2e-agent",
DiskFreeBytes: 1000000000,
DiskTotalBytes: 5000000000,
})
if err != nil {
t.Fatalf("SendHeartbeat: %v", err)
}
// 4. Send progress (50% → should trigger cancel control)
_, err = tr.SendProgress(ctx, StatusUpdate{
TaskID: "task-e2e-1",
Status: "downloading",
Progress: 50,
DownloadedBytes: 500,
TotalBytes: 1000,
SpeedBps: 100,
})
if err != nil {
t.Fatalf("SendProgress: %v", err)
}
// 5. Wait for control event (cancel)
select {
case event := <-tr.Events():
if event.Type != "control" {
t.Errorf("expected control event, got %s", event.Type)
}
if event.Control.Action != "cancel" {
t.Errorf("expected cancel, got %s", event.Control.Action)
}
if event.Control.TaskID != "task-e2e-1" {
t.Errorf("expected task-e2e-1, got %s", event.Control.TaskID)
}
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for cancel control")
}
// Verify server received all messages
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if len(receivedMessages) < 3 {
t.Fatalf("expected at least 3 messages, got %d", len(receivedMessages))
}
types := make([]string, len(receivedMessages))
for i, m := range receivedMessages {
types[i], _ = m["type"].(string)
}
expected := []string{"auth", "heartbeat", "progress"}
for _, exp := range expected {
found := false
for _, got := range types {
if got == exp {
found = true
break
}
}
if !found {
t.Errorf("missing message type %q in %v", exp, types)
}
}
}
// TestE2EHybridFailover tests the full failover scenario:
// WS connect → download → WS disconnect → switch to HTTP → continue working
func TestE2EHybridFailover(t *testing.T) {
connectionCount := 0
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
mu.Lock()
connectionCount++
connNum := connectionCount
mu.Unlock()
// Read auth
conn.ReadMessage()
conn.WriteJSON(wsRegisteredMessage{
Type: "registered",
User: UserInfo{Name: "Failover User"},
})
if connNum == 1 {
// First connection: push tasks then disconnect after 200ms
time.Sleep(50 * time.Millisecond)
conn.WriteJSON(wsTasksMessage{
Type: "tasks",
Tasks: []Task{{ID: "t1", InfoHash: "abc", Title: "Failover Movie"}},
})
time.Sleep(150 * time.Millisecond)
conn.Close()
} else {
// Second connection (after reconnect): push upgrade
time.Sleep(50 * time.Millisecond)
conn.WriteJSON(wsUpgradeMessage{Type: "upgrade", Version: "3.0.0"})
time.Sleep(500 * time.Millisecond)
conn.Close()
}
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
wsT := NewWSTransport(wsURL, "key", "a1", "ua")
// HTTP mock for fallback
httpSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Simple heartbeat response
json.NewEncoder(w).Encode(HeartbeatResponse{Success: true})
}))
defer httpSrv.Close()
httpT := NewHTTPTransport(httpSrv.URL, "key", "ua")
h := NewHybridTransport(wsT, httpT)
ctx := context.Background()
err := h.Connect(ctx)
if err != nil {
t.Fatalf("Connect: %v", err)
}
defer h.Close()
// Should start in WS mode
if h.Mode() != "ws" {
t.Fatalf("expected ws mode, got %s", h.Mode())
}
// Register via WS
_, err = h.Register(ctx, RegisterRequest{AgentID: "a1"})
if err != nil {
t.Fatalf("Register: %v", err)
}
// Receive tasks via WS
var tasksReceived bool
var disconnected bool
for i := 0; i < 3; i++ {
select {
case event := <-h.Events():
switch event.Type {
case "tasks":
tasksReceived = true
if len(event.Tasks.Tasks) != 1 || event.Tasks.Tasks[0].Title != "Failover Movie" {
t.Errorf("unexpected tasks: %+v", event.Tasks)
}
case "disconnected":
disconnected = true
}
case <-time.After(2 * time.Second):
break
}
if disconnected {
break
}
}
if !tasksReceived {
t.Error("did not receive tasks before disconnect")
}
if !disconnected {
t.Error("did not receive disconnect event")
}
// Should now be in HTTP mode
time.Sleep(100 * time.Millisecond)
if h.Mode() != "http" {
t.Errorf("expected http mode after disconnect, got %s", h.Mode())
}
// Heartbeat should work via HTTP fallback
hbResp, err := h.SendHeartbeat(ctx, HeartbeatRequest{AgentID: "a1"})
if err != nil {
t.Fatalf("SendHeartbeat via HTTP fallback: %v", err)
}
if !hbResp.Success {
t.Error("expected heartbeat success")
}
}

View file

@ -1,50 +0,0 @@
package agent
import "context"
// HTTPTransport wraps the existing Client to implement Transport.
// This is a thin adapter — no behavioral changes from the current HTTP protocol.
type HTTPTransport struct {
client *Client
events chan ServerEvent
}
// NewHTTPTransport creates a new HTTP-based transport.
func NewHTTPTransport(baseURL, apiKey, userAgent string) *HTTPTransport {
return &HTTPTransport{
client: NewClient(baseURL, apiKey, userAgent),
events: make(chan ServerEvent, 10),
}
}
func (t *HTTPTransport) Connect(_ context.Context) error { return nil }
func (t *HTTPTransport) Close() error { return nil }
func (t *HTTPTransport) Mode() string { return "http" }
func (t *HTTPTransport) Events() <-chan ServerEvent { return t.events }
func (t *HTTPTransport) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
return t.client.Register(ctx, req)
}
func (t *HTTPTransport) SendHeartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
return t.client.Heartbeat(ctx, req)
}
func (t *HTTPTransport) SendProgress(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
return t.client.ReportStatus(ctx, update)
}
func (t *HTTPTransport) BatchReportStatus(ctx context.Context, updates []StatusUpdate) (*BatchStatusResponse, error) {
return t.client.BatchReportStatus(ctx, updates)
}
func (t *HTTPTransport) ClaimTasks(ctx context.Context, agentID string) (*TasksResponse, error) {
return t.client.ClaimTasks(ctx, agentID)
}
func (t *HTTPTransport) Deregister(ctx context.Context, agentID string) error {
return t.client.Deregister(ctx, agentID)
}
// Client returns the underlying HTTP client for direct use if needed.
func (t *HTTPTransport) Client() *Client { return t.client }

View file

@ -1,214 +0,0 @@
package agent
import (
"context"
"log"
"sync"
"sync/atomic"
"time"
)
// HybridTransport tries WebSocket first, falls back to HTTP if WS fails.
// Automatically reconnects WS in the background.
type HybridTransport struct {
ws *WSTransport
http *HTTPTransport
mode atomic.Value // "ws" or "http"
events chan ServerEvent
reconnectMu sync.Mutex
reconnectRunning bool
reconnectStop chan struct{}
closed atomic.Bool
}
// NewHybridTransport creates a transport that prefers WS with HTTP fallback.
func NewHybridTransport(ws *WSTransport, http *HTTPTransport) *HybridTransport {
h := &HybridTransport{
ws: ws,
http: http,
events: make(chan ServerEvent, 50),
reconnectStop: make(chan struct{}),
}
h.mode.Store("http") // start in HTTP, upgrade to WS on Connect
return h
}
func (h *HybridTransport) Mode() string { return h.mode.Load().(string) }
func (h *HybridTransport) Events() <-chan ServerEvent { return h.events }
// Connect tries WS first. If it fails, falls back to HTTP and starts reconnection loop.
func (h *HybridTransport) Connect(ctx context.Context) error {
// Try WebSocket first
if err := h.ws.Connect(ctx); err != nil {
log.Printf("[transport] WebSocket connect failed (%v), using HTTP fallback", err)
h.mode.Store("http")
h.startReconnectLoop()
return h.http.Connect(ctx)
}
h.mode.Store("ws")
log.Println("[transport] Connected via WebSocket")
// Forward WS events to unified channel + watch for disconnection
go h.forwardWSEvents()
return nil
}
// Close shuts down both transports and stops reconnection.
func (h *HybridTransport) Close() error {
h.closed.Store(true)
select {
case <-h.reconnectStop:
default:
close(h.reconnectStop)
}
_ = h.ws.Close()
return h.http.Close()
}
// Register delegates to the active transport.
func (h *HybridTransport) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
if h.mode.Load() == "ws" {
return h.ws.Register(ctx, req)
}
return h.http.Register(ctx, req)
}
// SendHeartbeat delegates to the active transport.
func (h *HybridTransport) SendHeartbeat(ctx context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
if h.mode.Load() == "ws" {
resp, err := h.ws.SendHeartbeat(ctx, req)
if err != nil {
// WS write failed — switch to HTTP
h.switchToHTTP()
return h.http.SendHeartbeat(ctx, req)
}
return resp, nil
}
return h.http.SendHeartbeat(ctx, req)
}
// SendProgress delegates to the active transport.
func (h *HybridTransport) SendProgress(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
if h.mode.Load() == "ws" {
resp, err := h.ws.SendProgress(ctx, update)
if err != nil {
h.switchToHTTP()
return h.http.SendProgress(ctx, update)
}
return resp, nil
}
return h.http.SendProgress(ctx, update)
}
// ClaimTasks delegates to the active transport.
func (h *HybridTransport) ClaimTasks(ctx context.Context, agentID string) (*TasksResponse, error) {
if h.mode.Load() == "ws" {
return h.ws.ClaimTasks(ctx, agentID) // no-op in WS mode
}
return h.http.ClaimTasks(ctx, agentID)
}
// Deregister delegates to the active transport.
func (h *HybridTransport) Deregister(ctx context.Context, agentID string) error {
if h.mode.Load() == "ws" {
return h.ws.Deregister(ctx, agentID)
}
return h.http.Deregister(ctx, agentID)
}
// ── Internal ─────────────────────────────────────────────────────────────────
func (h *HybridTransport) switchToHTTP() {
if h.mode.Load() == "http" {
return
}
log.Println("[transport] Switching to HTTP fallback")
h.mode.Store("http")
_ = h.ws.Close()
h.startReconnectLoop()
}
func (h *HybridTransport) forwardWSEvents() {
for {
select {
case <-h.reconnectStop:
return
case event, ok := <-h.ws.Events():
if !ok {
return // channel closed
}
if event.Type == "disconnected" {
h.switchToHTTP()
select {
case h.events <- event:
default:
}
return
}
select {
case h.events <- event:
default:
log.Printf("[transport] events channel full, dropping %s event", event.Type)
}
}
}
}
func (h *HybridTransport) startReconnectLoop() {
h.reconnectMu.Lock()
defer h.reconnectMu.Unlock()
if h.reconnectRunning {
return
}
h.reconnectRunning = true
go h.reconnectLoop()
}
func (h *HybridTransport) reconnectLoop() {
backoff := 5 * time.Second
maxBackoff := 60 * time.Second
for {
select {
case <-h.reconnectStop:
return
case <-time.After(backoff):
}
if h.closed.Load() {
return
}
// Already on WS? (someone else reconnected)
if h.mode.Load() == "ws" {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
err := h.ws.Connect(ctx)
cancel()
if err != nil {
log.Printf("[transport] WS reconnect failed: %v (retry in %v)", err, backoff)
backoff = min(backoff*2, maxBackoff)
continue
}
// WS reconnected — switch back
log.Println("[transport] WebSocket reconnected")
h.mode.Store("ws")
// Reset reconnect flag so loop can start again if WS drops
h.reconnectMu.Lock()
h.reconnectRunning = false
h.reconnectMu.Unlock()
// Forward events from new WS connection
go h.forwardWSEvents()
return
}
}

View file

@ -1,445 +0,0 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// ── HTTP Transport Tests ─────────────────────────────────────────────────────
func TestHTTPTransportMode(t *testing.T) {
tr := NewHTTPTransport("http://localhost", "key", "ua")
if tr.Mode() != "http" {
t.Errorf("expected http, got %s", tr.Mode())
}
}
func TestHTTPTransportEventsNeverEmit(t *testing.T) {
tr := NewHTTPTransport("http://localhost", "key", "ua")
select {
case <-tr.Events():
t.Error("events channel should never emit in HTTP mode")
case <-time.After(50 * time.Millisecond):
// expected
}
}
func TestHTTPTransportDelegates(t *testing.T) {
// Mock server for register
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(RegisterResponse{
Success: true,
User: UserInfo{Name: "Test", Plan: "pro"},
})
}))
defer srv.Close()
tr := NewHTTPTransport(srv.URL, "test-key", "test-agent")
resp, err := tr.Register(context.Background(), RegisterRequest{AgentID: "a1"})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if !resp.Success {
t.Error("expected success")
}
if resp.User.Name != "Test" {
t.Errorf("expected Test, got %s", resp.User.Name)
}
}
// ── WebSocket Transport Tests ────────────────────────────────────────────────
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func TestWSTransportConnectAndAuth(t *testing.T) {
var received wsAuthMessage
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
t.Fatalf("upgrade: %v", err)
}
defer conn.Close()
// Read auth message
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
mu.Lock()
json.Unmarshal(msg, &received)
mu.Unlock()
// Send registered response
conn.WriteJSON(wsRegisteredMessage{
Type: "registered",
User: UserInfo{Name: "WS User", Plan: "pro", IsPro: true},
Features: FeatureFlags{Torrent: true},
})
// Keep connection open
time.Sleep(500 * time.Millisecond)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "my-api-key", "agent-123", "test/1.0")
ctx := context.Background()
if err := tr.Connect(ctx); err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer tr.Close()
resp, err := tr.Register(ctx, RegisterRequest{
AgentID: "agent-123",
Name: "test-agent",
Version: "1.0.0",
})
if err != nil {
t.Fatalf("Register failed: %v", err)
}
if !resp.Success {
t.Error("expected success")
}
if resp.User.Name != "WS User" {
t.Errorf("expected WS User, got %s", resp.User.Name)
}
mu.Lock()
if received.APIKey != "my-api-key" {
t.Errorf("expected my-api-key, got %s", received.APIKey)
}
if received.AgentID != "agent-123" {
t.Errorf("expected agent-123, got %s", received.AgentID)
}
mu.Unlock()
}
func TestWSTransportReceiveTasks(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// Read auth
conn.ReadMessage()
conn.WriteJSON(wsRegisteredMessage{
Type: "registered",
User: UserInfo{Name: "Test"},
})
// Push tasks
time.Sleep(50 * time.Millisecond)
conn.WriteJSON(wsTasksMessage{
Type: "tasks",
Tasks: []Task{
{ID: "t1", InfoHash: "abc123", Title: "Test Movie"},
{ID: "t2", InfoHash: "def456", Title: "Test Show"},
},
})
time.Sleep(500 * time.Millisecond)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "key", "agent1", "ua")
ctx := context.Background()
tr.Connect(ctx)
defer tr.Close()
tr.Register(ctx, RegisterRequest{AgentID: "agent1"})
// Wait for tasks event
select {
case event := <-tr.Events():
if event.Type != "tasks" {
t.Errorf("expected tasks, got %s", event.Type)
}
if len(event.Tasks.Tasks) != 2 {
t.Errorf("expected 2 tasks, got %d", len(event.Tasks.Tasks))
}
if event.Tasks.Tasks[0].Title != "Test Movie" {
t.Errorf("expected Test Movie, got %s", event.Tasks.Tasks[0].Title)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for tasks event")
}
}
func TestWSTransportReceiveControl(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.ReadMessage()
conn.WriteJSON(wsRegisteredMessage{Type: "registered", User: UserInfo{}})
time.Sleep(50 * time.Millisecond)
conn.WriteJSON(map[string]string{
"type": "control",
"action": "cancel",
"taskId": "task-99",
})
time.Sleep(500 * time.Millisecond)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "key", "a1", "ua")
ctx := context.Background()
tr.Connect(ctx)
defer tr.Close()
tr.Register(ctx, RegisterRequest{AgentID: "a1"})
select {
case event := <-tr.Events():
if event.Type != "control" {
t.Errorf("expected control, got %s", event.Type)
}
if event.Control.Action != "cancel" {
t.Errorf("expected cancel, got %s", event.Control.Action)
}
if event.Control.TaskID != "task-99" {
t.Errorf("expected task-99, got %s", event.Control.TaskID)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for control event")
}
}
func TestWSTransportReceiveUpgrade(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
conn.ReadMessage()
conn.WriteJSON(wsRegisteredMessage{Type: "registered", User: UserInfo{}})
time.Sleep(50 * time.Millisecond)
conn.WriteJSON(wsUpgradeMessage{Type: "upgrade", Version: "2.0.0"})
time.Sleep(500 * time.Millisecond)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "key", "a1", "ua")
ctx := context.Background()
tr.Connect(ctx)
defer tr.Close()
tr.Register(ctx, RegisterRequest{AgentID: "a1"})
select {
case event := <-tr.Events():
if event.Type != "upgrade" {
t.Errorf("expected upgrade, got %s", event.Type)
}
if event.Upgrade.Version != "2.0.0" {
t.Errorf("expected 2.0.0, got %s", event.Upgrade.Version)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for upgrade event")
}
}
func TestWSTransportDisconnect(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
conn.ReadMessage()
conn.WriteJSON(wsRegisteredMessage{Type: "registered", User: UserInfo{}})
// Close after a short delay to simulate disconnection
time.Sleep(100 * time.Millisecond)
conn.Close()
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "key", "a1", "ua")
ctx := context.Background()
tr.Connect(ctx)
defer tr.Close()
tr.Register(ctx, RegisterRequest{AgentID: "a1"})
select {
case event := <-tr.Events():
if event.Type != "disconnected" {
t.Errorf("expected disconnected, got %s", event.Type)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for disconnected event")
}
}
func TestWSTransportSendProgress(t *testing.T) {
var receivedMsg map[string]interface{}
var mu sync.Mutex
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
// Read auth
conn.ReadMessage()
conn.WriteJSON(wsRegisteredMessage{Type: "registered", User: UserInfo{}})
// Read progress
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
mu.Lock()
json.Unmarshal(msg, &receivedMsg)
mu.Unlock()
time.Sleep(500 * time.Millisecond)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
tr := NewWSTransport(wsURL, "key", "a1", "ua")
ctx := context.Background()
tr.Connect(ctx)
defer tr.Close()
tr.Register(ctx, RegisterRequest{AgentID: "a1"})
time.Sleep(50 * time.Millisecond)
resp, err := tr.SendProgress(ctx, StatusUpdate{
TaskID: "t1",
Status: "downloading",
Progress: 42,
})
if err != nil {
t.Fatalf("SendProgress failed: %v", err)
}
if !resp.Success {
t.Error("expected success response")
}
time.Sleep(100 * time.Millisecond)
mu.Lock()
if receivedMsg["type"] != "progress" {
t.Errorf("expected progress, got %v", receivedMsg["type"])
}
if receivedMsg["taskId"] != "t1" {
t.Errorf("expected t1, got %v", receivedMsg["taskId"])
}
mu.Unlock()
}
// ── Hybrid Transport Tests ───────────────────────────────────────────────────
func TestHybridTransportWSSuccess(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer conn.Close()
time.Sleep(500 * time.Millisecond)
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
wsT := NewWSTransport(wsURL, "key", "a1", "ua")
httpT := NewHTTPTransport("http://localhost", "key", "ua")
h := NewHybridTransport(wsT, httpT)
err := h.Connect(context.Background())
if err != nil {
t.Fatalf("Connect failed: %v", err)
}
defer h.Close()
if h.Mode() != "ws" {
t.Errorf("expected ws mode, got %s", h.Mode())
}
}
func TestHybridTransportWSFailFallbackHTTP(t *testing.T) {
// WS URL points to nowhere
wsT := NewWSTransport("ws://127.0.0.1:1", "key", "a1", "ua")
httpT := NewHTTPTransport("http://localhost", "key", "ua")
h := NewHybridTransport(wsT, httpT)
err := h.Connect(context.Background())
if err != nil {
t.Fatalf("Connect should succeed with HTTP fallback: %v", err)
}
defer h.Close()
if h.Mode() != "http" {
t.Errorf("expected http mode after WS failure, got %s", h.Mode())
}
}
func TestHybridTransportWSDisconnectSwitchesToHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
// Close immediately to trigger disconnect
time.Sleep(100 * time.Millisecond)
conn.Close()
}))
defer srv.Close()
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http")
wsT := NewWSTransport(wsURL, "key", "a1", "ua")
httpT := NewHTTPTransport("http://localhost", "key", "ua")
h := NewHybridTransport(wsT, httpT)
h.Connect(context.Background())
defer h.Close()
// Wait for disconnect event
select {
case event := <-h.Events():
if event.Type != "disconnected" {
t.Errorf("expected disconnected, got %s", event.Type)
}
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for disconnected event")
}
// Mode should be HTTP now
time.Sleep(100 * time.Millisecond)
if h.Mode() != "http" {
t.Errorf("expected http after disconnect, got %s", h.Mode())
}
}

View file

@ -1,349 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
// WSTransport communicates with the server via WebSocket through a Cloudflare Durable Object.
type WSTransport struct {
wsURL string // wss://unarr.torrentclaw.com/ws/{agentId}
apiKey string
agentID string
userAgent string
conn *websocket.Conn
mu sync.Mutex
events chan ServerEvent
closed atomic.Bool
// Cached auth response from the DO
authResp *RegisterResponse
authMu sync.Mutex
authDone chan struct{}
authDoneOnce sync.Once
}
// NewWSTransport creates a WebSocket-based transport.
func NewWSTransport(wsURL, apiKey, agentID, userAgent string) *WSTransport {
return &WSTransport{
wsURL: wsURL,
apiKey: apiKey,
agentID: agentID,
userAgent: userAgent,
events: make(chan ServerEvent, 50),
authDone: make(chan struct{}),
}
}
func (t *WSTransport) Mode() string { return "ws" }
func (t *WSTransport) Events() <-chan ServerEvent { return t.events }
// Connect dials the WebSocket server and starts the read loop.
func (t *WSTransport) Connect(ctx context.Context) error {
dialer := websocket.Dialer{
HandshakeTimeout: 10 * time.Second,
}
header := http.Header{}
header.Set("User-Agent", t.userAgent)
// Append API key as query param for auth on WS upgrade
wsURLWithKey := t.wsURL
if t.apiKey != "" {
sep := "?"
if strings.Contains(wsURLWithKey, "?") {
sep = "&"
}
wsURLWithKey += sep + "key=" + t.apiKey
}
conn, wsResp, err := dialer.DialContext(ctx, wsURLWithKey, header)
if wsResp != nil && wsResp.Body != nil {
defer wsResp.Body.Close()
}
if err != nil {
return fmt.Errorf("ws dial: %w", err)
}
t.mu.Lock()
t.conn = conn
t.closed.Store(false)
t.authDone = make(chan struct{})
t.authDoneOnce = sync.Once{}
t.mu.Unlock()
go t.readLoop(conn)
return nil
}
// Close sends a close frame and shuts down the connection.
func (t *WSTransport) Close() error {
t.closed.Store(true)
t.mu.Lock()
defer t.mu.Unlock()
if t.conn != nil {
_ = t.conn.WriteMessage(
websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
)
err := t.conn.Close()
t.conn = nil
return err
}
return nil
}
// Register sends auth message and waits for the registered response.
func (t *WSTransport) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
msg := wsAuthMessage{
Type: "auth",
APIKey: t.apiKey,
AgentID: req.AgentID,
Name: req.Name,
OS: req.OS,
Arch: req.Arch,
Version: req.Version,
DownloadDir: req.DownloadDir,
DiskFreeBytes: req.DiskFreeBytes,
DiskTotalBytes: req.DiskTotalBytes,
}
if err := t.send(msg); err != nil {
return nil, fmt.Errorf("ws auth send: %w", err)
}
// Wait for the auth response or context cancellation
select {
case <-t.authDone:
t.authMu.Lock()
resp := t.authResp
t.authMu.Unlock()
if resp == nil {
return nil, fmt.Errorf("ws auth: no response received")
}
return resp, nil
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(15 * time.Second):
return nil, fmt.Errorf("ws auth: timeout waiting for registered response")
}
}
// SendHeartbeat sends a heartbeat message. No blocking response in WS mode.
func (t *WSTransport) SendHeartbeat(_ context.Context, req HeartbeatRequest) (*HeartbeatResponse, error) {
msg := struct {
Type string `json:"type"`
Disk *struct {
Free int64 `json:"free"`
Total int64 `json:"total"`
} `json:"disk,omitempty"`
}{Type: "heartbeat"}
if req.DiskFreeBytes > 0 || req.DiskTotalBytes > 0 {
msg.Disk = &struct {
Free int64 `json:"free"`
Total int64 `json:"total"`
}{Free: req.DiskFreeBytes, Total: req.DiskTotalBytes}
}
if err := t.send(msg); err != nil {
return nil, err
}
// WS mode: heartbeat is fire-and-forget. Upgrade signals arrive via Events().
return &HeartbeatResponse{Success: true}, nil
}
// SendProgress sends a progress update. Control signals arrive async via Events().
func (t *WSTransport) SendProgress(_ context.Context, update StatusUpdate) (*StatusResponse, error) {
msg := struct {
Type string `json:"type"`
TaskID string `json:"taskId"`
Status string `json:"status,omitempty"`
Progress int `json:"progress,omitempty"`
DownloadedBytes int64 `json:"downloadedBytes,omitempty"`
TotalBytes int64 `json:"totalBytes,omitempty"`
SpeedBps int64 `json:"speedBps,omitempty"`
ETA int `json:"eta,omitempty"`
ResolvedMethod string `json:"resolvedMethod,omitempty"`
FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"`
StreamURL string `json:"streamUrl,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
}{
Type: "progress",
TaskID: update.TaskID,
Status: update.Status,
Progress: update.Progress,
DownloadedBytes: update.DownloadedBytes,
TotalBytes: update.TotalBytes,
SpeedBps: update.SpeedBps,
ETA: update.ETA,
ResolvedMethod: update.ResolvedMethod,
FileName: update.FileName,
FilePath: update.FilePath,
StreamURL: update.StreamURL,
ErrorMessage: update.ErrorMessage,
}
if err := t.send(msg); err != nil {
return nil, err
}
// In WS mode, control signals come via Events(), not in the progress response.
return &StatusResponse{Success: true}, nil
}
// ClaimTasks is a no-op in WS mode — tasks arrive via Events().
func (t *WSTransport) ClaimTasks(_ context.Context, _ string) (*TasksResponse, error) {
return &TasksResponse{}, nil
}
// Deregister is handled by WebSocket close (DO detects disconnection).
func (t *WSTransport) Deregister(_ context.Context, _ string) error {
return t.Close()
}
// ── Internal ─────────────────────────────────────────────────────────────────
func (t *WSTransport) send(msg any) error {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return fmt.Errorf("ws: not connected")
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
return t.conn.WriteMessage(websocket.TextMessage, data)
}
func (t *WSTransport) readLoop(conn *websocket.Conn) {
for {
_, msg, err := conn.ReadMessage()
if err != nil {
if !t.closed.Load() {
log.Printf("[ws] read error: %v", err)
// Signal disconnection to the daemon
select {
case t.events <- ServerEvent{Type: "disconnected"}:
default:
}
}
return
}
var envelope struct {
Type string `json:"type"`
}
if err := json.Unmarshal(msg, &envelope); err != nil {
log.Printf("[ws] invalid message: %v", err)
continue
}
switch envelope.Type {
case "registered":
var resp wsRegisteredMessage
if json.Unmarshal(msg, &resp) == nil {
t.authMu.Lock()
t.authResp = &RegisterResponse{
Success: true,
User: resp.User,
Features: resp.Features,
}
t.authMu.Unlock()
// Signal that auth is complete (sync.Once prevents double-close panic)
t.authDoneOnce.Do(func() { close(t.authDone) })
}
case "tasks":
var resp wsTasksMessage
if json.Unmarshal(msg, &resp) == nil {
select {
case t.events <- ServerEvent{
Type: "tasks",
Tasks: &TasksResponse{
Tasks: resp.Tasks,
StreamRequests: resp.StreamRequests,
},
}:
default:
log.Printf("[ws] events channel full, dropping tasks message")
}
}
case "upgrade":
var resp wsUpgradeMessage
if json.Unmarshal(msg, &resp) == nil {
select {
case t.events <- ServerEvent{
Type: "upgrade",
Upgrade: &UpgradeSignal{Version: resp.Version},
}:
default:
}
}
case "control":
var resp ControlAction
if json.Unmarshal(msg, &resp) == nil {
select {
case t.events <- ServerEvent{
Type: "control",
Control: &resp,
}:
default:
}
}
case "error":
var resp struct {
Message string `json:"message"`
}
if json.Unmarshal(msg, &resp) == nil {
log.Printf("[ws] server error: %s", resp.Message)
}
}
}
}
// ── WS message types ─────────────────────────────────────────────────────────
type wsAuthMessage struct {
Type string `json:"type"`
APIKey string `json:"apiKey"`
AgentID string `json:"agentId"`
Name string `json:"name,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Version string `json:"version,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
}
type wsRegisteredMessage struct {
Type string `json:"type"`
User UserInfo `json:"user"`
Features FeatureFlags `json:"features"`
}
type wsTasksMessage struct {
Type string `json:"type"`
Tasks []Task `json:"tasks"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
}
type wsUpgradeMessage struct {
Type string `json:"type"`
Version string `json:"version"`
}

View file

@ -1,6 +1,9 @@
package agent package agent
import "time" import (
"fmt"
"time"
)
// RegisterRequest is sent by the CLI on startup to register itself. // RegisterRequest is sent by the CLI on startup to register itself.
type RegisterRequest struct { type RegisterRequest struct {
@ -12,6 +15,37 @@ type RegisterRequest struct {
DownloadDir string `json:"downloadDir,omitempty"` DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"` DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"` DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
// Transcode capabilities — let the web side suggest a smarter quality
// before the player even starts. HWAccel is the picked backend
// ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is
// the largest output resolution the agent can encode comfortably; for
// software-only ffmpeg this is 1080p, with a real GPU encoder it goes
// 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. // RegisterResponse is returned by the server after registration.
@ -44,17 +78,6 @@ type UsenetServerInfo struct {
SSL bool `json:"ssl"` SSL bool `json:"ssl"`
} }
// HeartbeatRequest is sent every 30s to keep the agent alive.
type HeartbeatRequest struct {
AgentID string `json:"agentId"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
}
// Task represents a download task claimed from the server. // Task represents a download task claimed from the server.
type Task struct { type Task struct {
ID string `json:"id"` ID string `json:"id"`
@ -71,12 +94,18 @@ type Task struct {
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode) ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
ForceStart bool `json:"forceStart,omitempty"` // Bypass queue (like Transmission's Force Start) ForceStart bool `json:"forceStart,omitempty"` // Bypass queue (like Transmission's Force Start)
} ContentType string `json:"contentType,omitempty"` // "movie" | "show" — from server metadata
ContentTitle string `json:"contentTitle,omitempty"` // Clean title from TMDB (e.g., "Frieren: Beyond Journey's End")
Season *int `json:"season,omitempty"` // Season number
Episode *int `json:"episode,omitempty"` // Episode number
ContentYear *int `json:"contentYear,omitempty"` // Year from TMDB (avoids regex on torrent title)
CollectionName string `json:"collectionName,omitempty"` // Collection name (e.g., "Harry Potter Collection")
// TasksResponse wraps the array of tasks returned by the server. // FilePath is the on-disk path of the file the agent is being asked
type TasksResponse struct { // to operate on. Currently used by mode=seed_file to know which
Tasks []Task `json:"tasks"` // arbitrary file to wrap as a single-file torrent for browser
StreamRequests []StreamRequest `json:"streamRequests,omitempty"` // streaming; populated by the server from libraryItem.filePath.
FilePath string `json:"filePath,omitempty"`
} }
// StreamRequest is a request to stream a completed download from disk. // StreamRequest is a request to stream a completed download from disk.
@ -98,7 +127,11 @@ type StatusUpdate struct {
FileName string `json:"fileName,omitempty"` FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"` FilePath string `json:"filePath,omitempty"`
StreamURL string `json:"streamUrl,omitempty"` StreamURL string `json:"streamUrl,omitempty"`
StreamReady bool `json:"streamReady,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"`
// mode=seed_file: agent computes the info_hash from the local file
// and reports it back so the web player can target /stream/<hash>.
InfoHash string `json:"infoHash,omitempty"`
} }
// StatusResponse is returned by the status endpoint. // StatusResponse is returned by the status endpoint.
@ -109,6 +142,7 @@ type StatusResponse struct {
Paused bool `json:"paused,omitempty"` Paused bool `json:"paused,omitempty"`
DeleteFiles bool `json:"deleteFiles,omitempty"` DeleteFiles bool `json:"deleteFiles,omitempty"`
StreamRequested bool `json:"streamRequested,omitempty"` StreamRequested bool `json:"streamRequested,omitempty"`
Watching bool `json:"watching,omitempty"`
} }
// BatchStatusRequest wraps multiple status updates in a single request. // BatchStatusRequest wraps multiple status updates in a single request.
@ -118,14 +152,8 @@ type BatchStatusRequest struct {
// BatchStatusResponse wraps per-task results from the batch endpoint. // BatchStatusResponse wraps per-task results from the batch endpoint.
type BatchStatusResponse struct { type BatchStatusResponse struct {
Results []StatusResponse `json:"results"` Results []StatusResponse `json:"results"`
} Watching bool `json:"watching,omitempty"`
// HeartbeatResponse is returned by the server on heartbeat.
type HeartbeatResponse struct {
Success bool `json:"success"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Watching bool `json:"watching,omitempty"` // true when a user is viewing download progress in the web UI
} }
// UpgradeSignal tells the agent to upgrade to a specific version. // UpgradeSignal tells the agent to upgrade to a specific version.
@ -139,6 +167,17 @@ type ErrorResponse struct {
Details any `json:"details,omitempty"` Details any `json:"details,omitempty"`
} }
// HTTPError represents an HTTP API error with a status code.
// Use errors.As to extract the status code for retry decisions.
type HTTPError struct {
StatusCode int
Message string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}
// AgentInfo holds metadata about the running agent for display. // AgentInfo holds metadata about the running agent for display.
type AgentInfo struct { type AgentInfo struct {
ID string ID string
@ -146,7 +185,6 @@ type AgentInfo struct {
User UserInfo User UserInfo
Features FeatureFlags Features FeatureFlags
StartedAt time.Time StartedAt time.Time
LastPollAt time.Time
ActiveTasks int ActiveTasks int
} }
@ -268,9 +306,10 @@ type DebridAccount struct {
// LibrarySyncRequest sends scanned media items to the server. // LibrarySyncRequest sends scanned media items to the server.
type LibrarySyncRequest struct { type LibrarySyncRequest struct {
Items []LibrarySyncItem `json:"items"` Items []LibrarySyncItem `json:"items"`
ScanPath string `json:"scanPath"` ScanPath string `json:"scanPath"`
IsLastBatch bool `json:"isLastBatch"` IsLastBatch bool `json:"isLastBatch"`
SyncStartedAt string `json:"syncStartedAt,omitempty"` // ISO-8601; same for all batches in a session
} }
// LibrarySyncItem is a single scanned media file with ffprobe metadata. // LibrarySyncItem is a single scanned media file with ffprobe metadata.
@ -302,3 +341,101 @@ type LibrarySyncResponse struct {
Matched int `json:"matched"` Matched int `json:"matched"`
Removed int `json:"removed"` Removed int `json:"removed"`
} }
// ---------------------------------------------------------------------------
// Sync types (unified CLI ↔ Server communication)
// ---------------------------------------------------------------------------
// SyncRequest is sent by the CLI periodically to synchronize state with the server.
// Contains the CLI's full execution state — the server responds with pending actions.
type SyncRequest struct {
AgentID string `json:"agentId"`
Version string `json:"version,omitempty"`
OS string `json:"os,omitempty"`
Arch string `json:"arch,omitempty"`
Name string `json:"name,omitempty"`
DownloadDir string `json:"downloadDir,omitempty"`
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
FreeSlots int `json:"freeSlots"`
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.
type ControlAction struct {
Action string `json:"action"` // "pause", "resume", "cancel", "stream"
TaskID string `json:"taskId"`
DeleteFiles bool `json:"deleteFiles,omitempty"`
}
// LibraryDeleteRequest is a server-side request to delete a file from disk.
type LibraryDeleteRequest struct {
ItemID int `json:"itemId"`
FilePath string `json:"filePath"`
}
// 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".
AudioIndex int `json:"audioIndex,omitempty"`
}
// SyncResponse is returned by the server with all pending actions for the CLI.
type SyncResponse struct {
NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"`
FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
}
// ---------------------------------------------------------------------------
// Watch progress types (used by stream tracking)
// ---------------------------------------------------------------------------
// WatchProgressUpdate reports playback position during streaming.
// Two modes:
// - Estimated (range): set Progress (0-100). Position/Duration omitted.
// - Precise (browser): set Position + Duration in seconds. Progress computed server-side.
type WatchProgressUpdate struct {
TaskID string `json:"taskId"`
Source string `json:"source"` // "range" or "browser"
Progress *int `json:"progress,omitempty"` // 0-100 (range source)
Position *int `json:"position,omitempty"` // seconds (browser source)
Duration *int `json:"duration,omitempty"` // seconds (browser source)
}
// WatchProgressResponse is returned after reporting watch progress.
type WatchProgressResponse struct {
Success bool `json:"success"`
}

396
internal/arr/client_test.go Normal file
View file

@ -0,0 +1,396 @@
package arr
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func newTestServer(t *testing.T, handlers map[string]any) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check API key header
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
handler, ok := handlers[r.URL.Path]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(handler)
}))
}
func TestNewClient(t *testing.T) {
c := NewClient("http://localhost:8989/", "mykey")
if c.baseURL != "http://localhost:8989" {
t.Errorf("baseURL = %q, want trailing slash trimmed", c.baseURL)
}
if c.apiKey != "mykey" {
t.Errorf("apiKey = %q, want mykey", c.apiKey)
}
}
func TestSystemStatus(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/system/status": SystemStatus{AppName: "Radarr", Version: "4.0.0"},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
status, err := c.SystemStatus()
if err != nil {
t.Fatalf("SystemStatus: %v", err)
}
if status.AppName != "Radarr" {
t.Errorf("AppName = %q, want Radarr", status.AppName)
}
if status.Version != "4.0.0" {
t.Errorf("Version = %q, want 4.0.0", status.Version)
}
}
func TestSystemStatusFallbackV1(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
switch r.URL.Path {
case "/api/v3/system/status":
w.WriteHeader(http.StatusNotFound)
case "/api/v1/system/status":
json.NewEncoder(w).Encode(SystemStatus{AppName: "Prowlarr", Version: "1.0.0"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
status, err := c.SystemStatus()
if err != nil {
t.Fatalf("SystemStatus v1 fallback: %v", err)
}
if status.AppName != "Prowlarr" {
t.Errorf("AppName = %q, want Prowlarr", status.AppName)
}
}
func TestMovies(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/movie": []Movie{
{ID: 1, Title: "Inception", Year: 2010, TmdbID: 27205, Monitored: true},
{ID: 2, Title: "Tenet", Year: 2020, TmdbID: 577922, HasFile: true},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
movies, err := c.Movies()
if err != nil {
t.Fatalf("Movies: %v", err)
}
if len(movies) != 2 {
t.Fatalf("expected 2 movies, got %d", len(movies))
}
if movies[0].Title != "Inception" {
t.Errorf("movies[0].Title = %q, want Inception", movies[0].Title)
}
}
func TestSeries(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/series": []Series{
{ID: 1, Title: "Breaking Bad", Year: 2008, TvdbID: 81189},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
series, err := c.Series()
if err != nil {
t.Fatalf("Series: %v", err)
}
if len(series) != 1 {
t.Fatalf("expected 1 series, got %d", len(series))
}
if series[0].Title != "Breaking Bad" {
t.Errorf("series[0].Title = %q, want Breaking Bad", series[0].Title)
}
}
func TestQualityProfiles(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/qualityprofile": []QualityProfile{
{ID: 1, Name: "HD-1080p"},
{ID: 2, Name: "Ultra-HD"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
profiles, err := c.QualityProfiles()
if err != nil {
t.Fatalf("QualityProfiles: %v", err)
}
if len(profiles) != 2 {
t.Fatalf("expected 2 profiles, got %d", len(profiles))
}
}
func TestRootFolders(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/rootfolder": []RootFolder{
{ID: 1, Path: "/movies", FreeSpace: 500000000000},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
folders, err := c.RootFolders()
if err != nil {
t.Fatalf("RootFolders: %v", err)
}
if len(folders) != 1 {
t.Fatalf("expected 1 folder, got %d", len(folders))
}
if folders[0].Path != "/movies" {
t.Errorf("path = %q, want /movies", folders[0].Path)
}
}
func TestDownloadClients(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/downloadclient": []DownloadClient{
{ID: 1, Name: "Transmission", Enable: true, Protocol: "torrent"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
clients, err := c.DownloadClients()
if err != nil {
t.Fatalf("DownloadClients: %v", err)
}
if len(clients) != 1 || clients[0].Name != "Transmission" {
t.Errorf("unexpected clients: %+v", clients)
}
}
func TestDownloadClientDetails(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == "/api/v3/downloadclient/5" {
json.NewEncoder(w).Encode(struct {
Fields []Field `json:"fields"`
}{
Fields: []Field{
{Name: "host", Value: "localhost"},
{Name: "port", Value: 9091},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
fields, err := c.DownloadClientDetails(5)
if err != nil {
t.Fatalf("DownloadClientDetails: %v", err)
}
if len(fields) != 2 {
t.Fatalf("expected 2 fields, got %d", len(fields))
}
if fields[0].Name != "host" {
t.Errorf("fields[0].Name = %q, want host", fields[0].Name)
}
}
func TestTags(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v3/tag": []Tag{
{ID: 1, Label: "unarr"},
{ID: 2, Label: "imported"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
tags, err := c.Tags()
if err != nil {
t.Fatalf("Tags: %v", err)
}
if len(tags) != 2 {
t.Fatalf("expected 2 tags, got %d", len(tags))
}
}
func TestHistory(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == "/api/v3/history" {
json.NewEncoder(w).Encode(HistoryResponse{
Records: []HistoryRecord{
{ID: 1, EventType: "grabbed", SourceTitle: "Inception.2010.1080p"},
},
TotalRecords: 1,
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
records, err := c.History(10)
if err != nil {
t.Fatalf("History: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected 1 record, got %d", len(records))
}
if records[0].SourceTitle != "Inception.2010.1080p" {
t.Errorf("sourceTitle = %q", records[0].SourceTitle)
}
}
func TestHistoryDefaultPageSize(t *testing.T) {
var requestedPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
requestedPath = r.URL.String()
json.NewEncoder(w).Encode(HistoryResponse{})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
c.History(0) // should default to 250
if requestedPath == "" {
t.Fatal("no request made")
}
if !contains(requestedPath, "pageSize=250") {
t.Errorf("expected pageSize=250, got path: %s", requestedPath)
}
}
func TestBlocklist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "test-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == "/api/v3/blocklist" {
json.NewEncoder(w).Encode(BlocklistResponse{
Records: []BlocklistItem{
{ID: 1, SourceTitle: "Bad.Release", Data: BlocklistData{InfoHash: "abc123"}},
},
})
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
items, err := c.Blocklist(50)
if err != nil {
t.Fatalf("Blocklist: %v", err)
}
if len(items) != 1 || items[0].Data.InfoHash != "abc123" {
t.Errorf("unexpected blocklist: %+v", items)
}
}
func TestIndexers(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v1/indexer": []Indexer{
{ID: 1, Name: "NZBGeek", Enable: true},
{ID: 2, Name: "Torznab", Enable: false},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
indexers, err := c.Indexers()
if err != nil {
t.Fatalf("Indexers: %v", err)
}
if len(indexers) != 2 {
t.Fatalf("expected 2 indexers, got %d", len(indexers))
}
}
func TestApplications(t *testing.T) {
srv := newTestServer(t, map[string]any{
"/api/v1/applications": []Application{
{ID: 1, Name: "Radarr"},
},
})
defer srv.Close()
c := NewClient(srv.URL, "test-key")
apps, err := c.Applications()
if err != nil {
t.Fatalf("Applications: %v", err)
}
if len(apps) != 1 || apps[0].Name != "Radarr" {
t.Errorf("unexpected apps: %+v", apps)
}
}
func TestUnauthorized(t *testing.T) {
srv := newTestServer(t, map[string]any{})
defer srv.Close()
c := NewClient(srv.URL, "wrong-key")
_, err := c.SystemStatus()
if err == nil {
t.Error("expected error for unauthorized request")
}
}
func TestHTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte("internal error"))
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key")
_, err := c.Movies()
if err == nil {
t.Error("expected error for 500 response")
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchStr(s, substr)
}
func searchStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View file

@ -1,6 +1,9 @@
package arr package arr
import ( import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings" "strings"
"testing" "testing"
) )
@ -82,3 +85,158 @@ func TestDetectApp(t *testing.T) {
}) })
} }
} }
func TestConfigDirs(t *testing.T) {
dirs := configDirs()
if len(dirs) == 0 {
t.Error("configDirs() returned empty")
}
}
func TestParseConfigXMLEmpty(t *testing.T) {
port, apiKey, urlBase := parseConfigXML(strings.NewReader(""))
if port != "" || apiKey != "" || urlBase != "" {
t.Error("empty input should return empty values")
}
}
func TestParseConfigXMLNoPort(t *testing.T) {
xml := `<Config><ApiKey>key123</ApiKey></Config>`
port, apiKey, _ := parseConfigXML(strings.NewReader(xml))
if port != "" {
t.Errorf("port = %q, want empty", port)
}
if apiKey != "key123" {
t.Errorf("apiKey = %q, want key123", apiKey)
}
}
func TestExtractHostPortMultipleMappings(t *testing.T) {
tests := []struct {
name string
ports string
container string
want string
}{
{"ipv6 only", ":::8989->8989/tcp", "8989", "8989"},
{"different host port", "0.0.0.0:9999->8989/tcp", "8989", "9999"},
{"port in string but no mapping", "something 8989 somewhere", "8989", "8989"},
{"no match at all", "0.0.0.0:3000->3000/tcp", "9999", ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractHostPort(tt.ports, tt.container)
if got != tt.want {
t.Errorf("extractHostPort(%q, %q) = %q, want %q", tt.ports, tt.container, got, tt.want)
}
})
}
}
func TestDiscoverFromProwlarr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/applications":
json.NewEncoder(w).Encode([]Application{
{
ID: 1,
Name: "Radarr",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:7878"},
{Name: "apiKey", Value: "radarr-key-123"},
},
},
{
ID: 2,
Name: "Sonarr",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:8989"},
{Name: "apiKey", Value: "sonarr-key-456"},
},
},
{
ID: 3,
Name: "Unknown App",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:9000"},
{Name: "apiKey", Value: "unknown-key"},
},
},
{
ID: 4,
Name: "Incomplete",
Fields: []Field{
{Name: "baseUrl", Value: "http://localhost:5000"},
// no apiKey → should be skipped
},
},
})
case "/api/v3/system/status":
json.NewEncoder(w).Encode(SystemStatus{AppName: "Radarr", Version: "4.0.0"})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
// DiscoverFromProwlarr will try to verify each instance, which will fail
// for localhost URLs (not our test server), but that's OK — we test the parsing
instances := DiscoverFromProwlarr(srv.URL, "prowlarr-key")
// Should find Radarr and Sonarr (Unknown and Incomplete skipped)
if len(instances) != 2 {
t.Fatalf("expected 2 instances, got %d: %+v", len(instances), instances)
}
found := map[string]bool{}
for _, inst := range instances {
found[inst.App] = true
if inst.Source != "prowlarr" {
t.Errorf("source = %q, want prowlarr", inst.Source)
}
}
if !found["radarr"] {
t.Error("expected radarr instance")
}
if !found["sonarr"] {
t.Error("expected sonarr instance")
}
}
func TestVerify(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Api-Key") != "valid-key" {
w.WriteHeader(http.StatusUnauthorized)
return
}
json.NewEncoder(w).Encode(SystemStatus{AppName: "Radarr", Version: "5.0.0"})
}))
defer srv.Close()
t.Run("valid", func(t *testing.T) {
inst := &Instance{App: "radarr", URL: srv.URL, APIKey: "valid-key"}
err := Verify(inst)
if err != nil {
t.Fatalf("Verify: %v", err)
}
if inst.Version != "5.0.0" {
t.Errorf("version = %q, want 5.0.0", inst.Version)
}
})
t.Run("no api key", func(t *testing.T) {
inst := &Instance{App: "radarr", URL: srv.URL}
err := Verify(inst)
if err == nil {
t.Error("expected error for no API key")
}
})
t.Run("invalid key", func(t *testing.T) {
inst := &Instance{App: "radarr", URL: srv.URL, APIKey: "wrong-key"}
err := Verify(inst)
if err == nil {
t.Error("expected error for invalid API key")
}
})
}

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

@ -14,7 +14,7 @@ import (
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
) )
var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"} var configCategories = []string{"downloads", "organization", "library", "notifications", "device", "region", "connection", "advanced"}
func newConfigCmd() *cobra.Command { func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
@ -25,6 +25,7 @@ func newConfigCmd() *cobra.Command {
Categories: Categories:
downloads Download directory, method, speed limits, concurrency downloads Download directory, method, speed limits, concurrency
organization Auto-sort into Movies / TV Shows folders organization Auto-sort into Movies / TV Shows folders
library Library scan settings and file deletion permissions
notifications Desktop notifications notifications Desktop notifications
device Agent name device Agent name
region Country and language region Country and language
@ -95,6 +96,7 @@ func runConfigMenu(category string) error {
Options( Options(
huh.NewOption("Downloads — directory, method, speed limits", "downloads"), huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"), huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"),
huh.NewOption("Library — scan settings & file deletion", "library"),
huh.NewOption("Notifications — desktop notifications", "notifications"), huh.NewOption("Notifications — desktop notifications", "notifications"),
huh.NewOption("Device — agent name", "device"), huh.NewOption("Device — agent name", "device"),
huh.NewOption("Region — country & language", "region"), huh.NewOption("Region — country & language", "region"),
@ -131,6 +133,8 @@ func runCategory(cfg *config.Config, category string) error {
return configDownloads(cfg) return configDownloads(cfg)
case "organization": case "organization":
return configOrganization(cfg) return configOrganization(cfg)
case "library":
return configLibrary(cfg)
case "notifications": case "notifications":
return configNotifications(cfg) return configNotifications(cfg)
case "device": case "device":
@ -311,23 +315,23 @@ func configConnection(cfg *config.Config) error {
).Run() ).Run()
} }
func configAdvanced(cfg *config.Config) error { func configLibrary(cfg *config.Config) error {
return huh.NewForm( return huh.NewForm(
huh.NewGroup( huh.NewGroup(
huh.NewInput(). huh.NewConfirm().
Title("Poll interval"). Title("Allow file deletion from web UI?").
Description("How often to check for new tasks (e.g. 30s, 1m)"). Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered.").
Value(&cfg.Daemon.PollInterval). Value(&cfg.Library.AllowDelete),
Validate(validateDuration),
huh.NewInput().
Title("Heartbeat interval").
Description("How often to send heartbeat to server (e.g. 30s, 1m)").
Value(&cfg.Daemon.HeartbeatInterval).
Validate(validateDuration),
), ),
).Run() ).Run()
} }
func configAdvanced(_ *config.Config) error {
// Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed.
fmt.Println("No advanced settings to configure. Sync intervals are automatic.")
return nil
}
// ── Validators ────────────────────────────────────────────────────── // ── Validators ──────────────────────────────────────────────────────
func validateSpeed(s string) error { func validateSpeed(s string) error {

View file

@ -0,0 +1,55 @@
package cmd
import "testing"
func TestValidateSpeed(t *testing.T) {
tests := []struct {
input string
wantErr bool
}{
{"", false},
{"0", false},
{" ", false},
{"10MB", false},
{"500KB", false},
{"1GB", false},
{"abc", true},
{"10XB", true},
{"-5MB", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
err := validateSpeed(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateSpeed(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestValidateDuration(t *testing.T) {
tests := []struct {
input string
wantErr bool
}{
{"", false},
{"30s", false},
{"1m", false},
{"5m", false},
{"1h", false},
{"2h30m", false},
{"abc", true},
{"30", true},
{"5x", true},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
err := validateDuration(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateDuration(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,335 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
func newDaemonStartCmd() *cobra.Command {
return &cobra.Command{
Use: "start",
Short: "Start the installed daemon service",
Long: `Start the unarr daemon using the system service manager.
Requires 'unarr daemon install' to have been run first.
Linux: systemctl --user start unarr
macOS: launchctl load ~/Library/LaunchAgents/com.torrentclaw.unarr.plist
Windows: schtasks /run /tn unarr`,
Example: ` unarr daemon start`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcStart()
},
}
}
func newDaemonStopCmd() *cobra.Command {
return &cobra.Command{
Use: "stop",
Short: "Stop the running daemon service",
Long: `Stop the unarr daemon service.
Linux: systemctl --user stop unarr
macOS: launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist
Windows: sends stop signal via process PID`,
Example: ` unarr daemon stop`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcStop()
},
}
}
func newDaemonRestartCmd() *cobra.Command {
return &cobra.Command{
Use: "restart",
Short: "Restart the daemon service",
Long: `Restart the unarr daemon service.
Linux: systemctl --user restart unarr
macOS: unload + reload launchd agent
Windows: stop by PID + schtasks /run`,
Example: ` unarr daemon restart`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcRestart()
},
}
}
func newDaemonSvcStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show daemon service status",
Long: `Show the current status of the unarr daemon service as reported
by the system service manager, plus local state information.`,
Example: ` unarr daemon status`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonSvcStatus()
},
}
}
func newDaemonLogsCmd() *cobra.Command {
var follow bool
var lines int
cmd := &cobra.Command{
Use: "logs",
Short: "Show daemon logs",
Long: `Show daemon log output.
Linux: streams from journald (journalctl --user -u unarr)
macOS: tails ~/.local/share/unarr/unarr.log
Windows: tails %LOCALAPPDATA%\unarr\unarr.log`,
Example: ` unarr daemon logs
unarr daemon logs -f
unarr daemon logs -n 100 -f`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonLogs(follow, lines)
},
}
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
return cmd
}
func newDaemonReloadCmd() *cobra.Command {
return &cobra.Command{
Use: "reload",
Short: "Reload daemon configuration without restarting",
Long: `Send a reload signal to the running daemon, causing it to
re-read its configuration file without interrupting active downloads.
Linux/macOS: sends SIGUSR1 to the daemon process
Windows: not supported (use 'unarr daemon restart' instead)`,
Example: ` unarr daemon reload`,
RunE: func(cmd *cobra.Command, args []string) error {
return runDaemonReload()
},
}
}
// ── Platform implementations ──────────────────────────────────────────────────
func runDaemonSvcStart() error {
fmt.Println()
switch runtime.GOOS {
case "linux":
if err := svcExec("systemctl", "--user", "start", "unarr"); err != nil {
fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.")
return fmt.Errorf("start service: %w", err)
}
case "darwin":
home, _ := os.UserHomeDir()
plist := launchdPlistPath(home)
if _, err := os.Stat(plist); err != nil {
return fmt.Errorf("service not installed — run 'unarr daemon install' first")
}
if err := svcExec("launchctl", "load", plist); err != nil {
return fmt.Errorf("load service: %w", err)
}
case "windows":
if err := svcExec("schtasks", "/run", "/tn", "unarr"); err != nil {
fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.")
return fmt.Errorf("start task: %w", err)
}
default:
return fmt.Errorf("service control not supported on %s", runtime.GOOS)
}
color.New(color.FgGreen).Println(" ✓ Started")
fmt.Println()
return nil
}
func runDaemonSvcStop() error {
fmt.Println()
switch runtime.GOOS {
case "linux":
if err := svcExec("systemctl", "--user", "stop", "unarr"); err != nil {
return fmt.Errorf("stop service: %w", err)
}
case "darwin":
home, _ := os.UserHomeDir()
plist := launchdPlistPath(home)
if err := svcExec("launchctl", "unload", plist); err != nil {
return fmt.Errorf("unload service: %w", err)
}
default:
return stopDaemonByPID()
}
color.New(color.FgGreen).Println(" ✓ Stopped")
fmt.Println()
return nil
}
func runDaemonSvcRestart() error {
switch runtime.GOOS {
case "linux":
fmt.Println()
if err := svcExec("systemctl", "--user", "restart", "unarr"); err != nil {
return fmt.Errorf("restart service: %w", err)
}
color.New(color.FgGreen).Println(" ✓ Restarted")
fmt.Println()
return nil
default:
fmt.Println(" Stopping...")
_ = runDaemonSvcStop()
fmt.Println(" Starting...")
return runDaemonSvcStart()
}
}
func runDaemonSvcStatus() error {
fmt.Println()
switch runtime.GOOS {
case "linux":
// systemctl gives rich formatted output; exit code non-zero when stopped is fine.
svcExec("systemctl", "--user", "status", "--no-pager", "unarr") //nolint:errcheck
case "darwin":
printDaemonStatusDarwin()
case "windows":
svcExec("schtasks", "/query", "/tn", "unarr", "/fo", "LIST") //nolint:errcheck
default:
fmt.Printf(" Service manager not supported on %s\n", runtime.GOOS)
}
printStateInfo()
return nil
}
func runDaemonLogs(follow bool, lines int) error {
switch runtime.GOOS {
case "linux":
args := []string{"--user", "-u", "unarr", "--no-pager", "-n", strconv.Itoa(lines)}
if follow {
// -f implies live output; drop --no-pager so journalctl can control the terminal.
args = []string{"--user", "-u", "unarr", "-f"}
}
return svcExecInteractive("journalctl", args...)
case "darwin":
home, _ := os.UserHomeDir()
logFile := filepath.Join(home, ".local", "share", "unarr", "unarr.log")
if _, err := os.Stat(logFile); err != nil {
fmt.Fprintln(os.Stderr, "The daemon writes this file when running as a launchd service. Run 'unarr daemon install' first.")
return fmt.Errorf("log file not found: %s", logFile)
}
args := []string{"-n", strconv.Itoa(lines)}
if follow {
args = append(args, "-f")
}
args = append(args, logFile)
return svcExecInteractive("tail", args...)
case "windows":
logFile := filepath.Join(config.DataDir(), "unarr.log")
if _, err := os.Stat(logFile); err != nil {
fmt.Fprintln(os.Stderr, "The daemon writes logs here when running. Start it first.")
return fmt.Errorf("log file not found: %s", logFile)
}
var psCmd string
if follow {
psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d -Wait", logFile, lines)
} else {
psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d", logFile, lines)
}
return svcExecInteractive("powershell", "-NonInteractive", "-Command", psCmd)
default:
return fmt.Errorf("log viewing not supported on %s", runtime.GOOS)
}
}
func runDaemonReload() error {
return sendReloadSignal()
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// 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, 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)
}
func launchdPlistPath(home string) string {
return filepath.Join(home, "Library", "LaunchAgents", "com.torrentclaw.unarr.plist")
}
// printDaemonStatusDarwin shows launchd service state by filtering launchctl output.
func printDaemonStatusDarwin() {
out, err := exec.Command("launchctl", "list").Output()
if err != nil {
fmt.Printf(" Could not query launchctl: %v\n", err)
return
}
found := false
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "unarr") {
// Format: PID ExitCode Label
fmt.Printf(" launchd: %s\n", strings.TrimSpace(line))
found = true
}
}
if !found {
fmt.Println(" launchd: service not loaded")
}
}
// printStateInfo shows information from the local daemon.state.json file.
func printStateInfo() {
state := agent.ReadState()
if state == nil {
color.New(color.FgHiBlack).Println(" State: no state file (daemon not running or crashed)")
fmt.Println()
return
}
dim := color.New(color.FgHiBlack)
fmt.Println()
dim.Println(" Local state:")
fmt.Printf(" PID: %d\n", state.PID)
fmt.Printf(" Status: %s\n", state.Status)
fmt.Printf(" Version: %s\n", state.Version)
fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt)))
fmt.Printf(" Heartbeat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat)))
fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks)
fmt.Println()
}
// svcExec runs a service management command with output flowing to the terminal.
func svcExec(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// svcExecInteractive is like svcExec but also connects stdin (needed for follow/pager modes).
func svcExecInteractive(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}

View file

@ -6,10 +6,14 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv"
"strings"
"text/template" "text/template"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
) )
const systemdTemplate = `[Unit] const systemdTemplate = `[Unit]
@ -22,11 +26,10 @@ Type=simple
ExecStart={{.BinPath}} start ExecStart={{.BinPath}} start
Restart=always Restart=always
RestartSec=10 RestartSec=10
User={{.User}}
Environment=HOME={{.Home}} Environment=HOME={{.Home}}
[Install] [Install]
WantedBy=multi-user.target WantedBy=default.target
` `
const launchdTemplate = `<?xml version="1.0" encoding="UTF-8"?> const launchdTemplate = `<?xml version="1.0" encoding="UTF-8"?>
@ -124,6 +127,8 @@ func runDaemonInstall() error {
return installSystemd(data, green) return installSystemd(data, green)
case "darwin": case "darwin":
return installLaunchd(data, green) return installLaunchd(data, green)
case "windows":
return installWindowsTask(data, green)
default: default:
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS) return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
} }
@ -229,6 +234,17 @@ func runDaemonUninstall() error {
os.Remove(path) os.Remove(path)
green.Printf(" ✓ Removed %s\n", path) green.Printf(" ✓ Removed %s\n", path)
case "windows":
// Stop the running process if any
if state := agent.ReadState(); state != nil {
exec.Command("taskkill", "/pid", strconv.Itoa(state.PID), "/f").Run()
}
out, err := exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").CombinedOutput()
if err != nil && !strings.Contains(string(out), "cannot find") {
return fmt.Errorf("remove scheduled task: %w\n%s", err, strings.TrimSpace(string(out)))
}
green.Println(" ✓ Scheduled task removed")
default: default:
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS) return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
} }
@ -236,3 +252,45 @@ func runDaemonUninstall() error {
fmt.Println() fmt.Println()
return nil return nil
} }
func installWindowsTask(data serviceData, green *color.Color) error {
logDir := config.DataDir()
os.MkdirAll(logDir, 0o755)
// Remove any existing task before (re)installing.
exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").Run()
// Wrap with PowerShell so stdout/stderr are captured to a log file.
psScript := fmt.Sprintf(
`Start-Transcript -Path '%s\unarr.log' -Append -NoClobber; & '%s' start`,
logDir, data.BinPath,
)
taskCmd := fmt.Sprintf(`powershell.exe -NonInteractive -WindowStyle Hidden -Command "%s"`, psScript)
out, err := exec.Command("schtasks",
"/create",
"/tn", "unarr",
"/tr", taskCmd,
"/sc", "onlogon",
"/ru", data.User,
"/rl", "highest",
"/f",
).CombinedOutput()
if err != nil {
return fmt.Errorf("create scheduled task: %w\n%s", err, strings.TrimSpace(string(out)))
}
fmt.Println()
green.Println(" ✓ Installed! Service will start automatically at next login.")
fmt.Println()
fmt.Println(" To start now:")
fmt.Println(" unarr daemon start")
fmt.Println()
fmt.Println(" Manage with:")
fmt.Println(" unarr daemon status")
fmt.Println(" unarr daemon stop")
fmt.Printf(" unarr daemon logs (log: %s\\unarr.log)\n", logDir)
fmt.Println()
return nil
}

View file

@ -0,0 +1,93 @@
package cmd
import (
"testing"
)
func TestIsAllowedStreamPath(t *testing.T) {
tests := []struct {
name string
filePath string
allowedDirs []string
want bool
}{
{
name: "path inside download dir",
filePath: "/downloads/movie.mkv",
allowedDirs: []string{"/downloads"},
want: true,
},
{
name: "path inside subdirectory",
filePath: "/downloads/sub/movie.mkv",
allowedDirs: []string{"/downloads"},
want: true,
},
{
name: "path traversal attempt",
filePath: "/downloads/../etc/passwd",
allowedDirs: []string{"/downloads"},
want: false,
},
{
name: "path outside all allowed dirs",
filePath: "/etc/passwd",
allowedDirs: []string{"/downloads", "/movies"},
want: false,
},
{
name: "path inside second allowed dir",
filePath: "/movies/action/movie.mkv",
allowedDirs: []string{"/downloads", "/movies"},
want: true,
},
{
name: "empty allowed dirs",
filePath: "/downloads/movie.mkv",
allowedDirs: []string{"", ""},
want: false,
},
{
name: "path equals allowed dir exactly",
filePath: "/downloads",
allowedDirs: []string{"/downloads"},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := isAllowedStreamPath(tt.filePath, tt.allowedDirs...)
if got != tt.want {
t.Errorf("isAllowedStreamPath(%q, %v) = %v, want %v",
tt.filePath, tt.allowedDirs, got, tt.want)
}
})
}
}
func TestFormatSpeedLog(t *testing.T) {
tests := []struct {
bps int64
want string
}{
{0, "0 B/s"},
{500, "500 B/s"},
{1023, "1023 B/s"},
{1024, "1 KB/s"},
{10240, "10 KB/s"},
{1048576, "1.0 MB/s"},
{5242880, "5.0 MB/s"},
{1073741824, "1.0 GB/s"},
{2147483648, "2.0 GB/s"},
}
for _, tt := range tests {
t.Run(tt.want, func(t *testing.T) {
got := formatSpeedLog(tt.bps)
if got != tt.want {
t.Errorf("formatSpeedLog(%d) = %q, want %q", tt.bps, got, tt.want)
}
})
}
}

View file

@ -17,6 +17,26 @@ import (
"github.com/torrentclaw/unarr/internal/parser" "github.com/torrentclaw/unarr/internal/parser"
) )
// downloadDeps agrupa las funciones constructoras usadas por runDownload.
// Pueden sobreescribirse en tests para inyectar mocks.
type downloadDeps struct {
newTorrentDl func(cfg engine.TorrentConfig) (engine.Downloader, error)
newDebridDl func() engine.Downloader
newAgentClient func(url, key, ua string) *agent.Client
newManager func(cfg engine.ManagerConfig, reporter *engine.ProgressReporter, dls ...engine.Downloader) *engine.Manager
}
var defaultDownloadDeps = downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return engine.NewTorrentDownloader(cfg)
},
newDebridDl: func() engine.Downloader {
return engine.NewDebridDownloader()
},
newAgentClient: agent.NewClient,
newManager: engine.NewManager,
}
func newDownloadCmd() *cobra.Command { func newDownloadCmd() *cobra.Command {
var method string var method string
@ -48,6 +68,10 @@ daemon instead: 'unarr start'.`,
} }
func runDownload(input, method string) error { func runDownload(input, method string) error {
return runDownloadWithDeps(input, method, defaultDownloadDeps)
}
func runDownloadWithDeps(input, method string, deps downloadDeps) error {
cfg := loadConfig() cfg := loadConfig()
bold := color.New(color.Bold) bold := color.New(color.Bold)
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
@ -84,7 +108,7 @@ func runDownload(input, method string) error {
fmt.Println() fmt.Println()
// Create torrent downloader // Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ torrentDl, err := deps.newTorrentDl(engine.TorrentConfig{
DataDir: outputDir, DataDir: outputDir,
MetadataTimeout: 15 * time.Minute, MetadataTimeout: 15 * time.Minute,
StallTimeout: 10 * time.Minute, StallTimeout: 10 * time.Minute,
@ -97,19 +121,20 @@ func runDownload(input, method string) error {
// Create a dummy reporter (no API reporting for one-shot) // Create a dummy reporter (no API reporting for one-shot)
reporter := engine.NewProgressReporter( reporter := engine.NewProgressReporter(
agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version), deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
5*time.Second, 5*time.Second,
) )
debridDl := engine.NewDebridDownloader() debridDl := deps.newDebridDl()
manager := engine.NewManager(engine.ManagerConfig{ manager := deps.newManager(engine.ManagerConfig{
MaxConcurrent: 1, MaxConcurrent: 1,
OutputDir: outputDir, OutputDir: outputDir,
Organize: engine.OrganizeConfig{ Organize: engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled, Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir, MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir, TVShowsDir: cfg.Organize.TVShowsDir,
OutputDir: outputDir,
}, },
}, reporter, torrentDl, debridDl) }, reporter, torrentDl, debridDl)

View file

@ -0,0 +1,397 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine"
)
// --- Mocks para tests del comando download ---
// testDownloader implementa engine.Downloader para tests.
type testDownloader struct {
method engine.DownloadMethod
available bool
filePath string // archivo a devolver como resultado
err error // si != nil, Download() devuelve este error
}
func (d *testDownloader) Method() engine.DownloadMethod { return d.method }
func (d *testDownloader) Available(_ context.Context, _ *engine.Task) (bool, error) {
return d.available, nil
}
func (d *testDownloader) Download(_ context.Context, _ *engine.Task, _ string, _ chan<- engine.Progress) (*engine.Result, error) {
if d.err != nil {
return nil, d.err
}
return &engine.Result{
FilePath: d.filePath,
FileName: filepath.Base(d.filePath),
Method: d.method,
Size: 1024,
}, nil
}
func (d *testDownloader) Pause(_ string) error { return nil }
func (d *testDownloader) Cancel(_ string) error { return nil }
func (d *testDownloader) Shutdown(_ context.Context) error { return nil }
// makeDepsWithDownloader crea un downloadDeps con un downloader mockeado.
func makeDepsWithDownloader(dl engine.Downloader) downloadDeps {
return downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return dl, nil
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid, available: false}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
}
// --- Tests de validación de entrada ---
func TestRunDownload_EmptyInput(t *testing.T) {
err := runDownload("", "torrent")
if err == nil {
t.Fatal("expected error for empty input")
}
}
func TestRunDownload_InvalidHash_TooShort(t *testing.T) {
err := runDownload("abc123", "torrent")
if err == nil {
t.Fatal("expected error for hash that is too short")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error = %q, want 'invalid' in message", err.Error())
}
}
func TestRunDownload_InvalidHash_NotHex_TooLong(t *testing.T) {
// 41 caracteres pero comienza con "magnet:" no → tampoco es un hash válido de 40 chars
err := runDownload("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "torrent") // 41 chars
if err == nil {
t.Fatal("expected error for 41-char string (not a valid hash)")
}
}
func TestRunDownload_ValidHash_40Chars(t *testing.T) {
// Un hash de 40 chars hex válido debe pasar la validación
// Usa deps que fallan inmediatamente para no necesitar red
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return nil, fmt.Errorf("test: stopping after validation")
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
// El error debe ser del downloader (no de validación)
if err == nil {
t.Fatal("expected error from newTorrentDl")
}
if strings.Contains(err.Error(), "invalid input") || strings.Contains(err.Error(), "invalid info hash") {
t.Errorf("error = %q — should not be a validation error, hash is valid", err.Error())
}
}
func TestRunDownload_InvalidInput_NotMagnetNotHash(t *testing.T) {
// Texto libre que no es ni hash ni magnet
err := runDownload("The Matrix 1999", "torrent")
if err == nil {
t.Fatal("expected error for plain text input")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error = %q, want 'invalid' in message", err.Error())
}
}
func TestRunDownload_InvalidInput_PartialMagnet(t *testing.T) {
// Prefix de magnet pero incompleto
err := runDownload("magnet:", "torrent")
if err == nil {
t.Fatal("expected error for incomplete magnet URI (no hash)")
}
}
// --- Tests con mock downloader ---
func TestRunDownload_Success(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
dl := &testDownloader{
method: engine.MethodTorrent,
available: true,
filePath: filePath,
}
deps := makeDepsWithDownloader(dl)
// Sobreescribir outputDir usando config vacía (usa home por defecto)
// Para un test determinista, usar una config con dir específico
deps.newTorrentDl = func(cfg engine.TorrentConfig) (engine.Downloader, error) {
// Actualizar filePath al outputDir real
realPath := filepath.Join(cfg.DataDir, "movie.mkv")
os.WriteFile(realPath, make([]byte, 1024), 0o644) //nolint:errcheck
return &testDownloader{
method: engine.MethodTorrent,
available: true,
filePath: realPath,
}, nil
}
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestRunDownload_DownloaderCreationFails(t *testing.T) {
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return nil, fmt.Errorf("failed to create torrent client")
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
if err == nil {
t.Fatal("expected error when downloader creation fails")
}
if !strings.Contains(err.Error(), "create downloader") {
t.Errorf("error = %q, want 'create downloader' in message", err.Error())
}
}
func TestRunDownload_DownloadFails(t *testing.T) {
dl := &testDownloader{
method: engine.MethodTorrent,
available: true,
err: errors.New("torrent: no peers"),
}
deps := makeDepsWithDownloader(dl)
// Sin fallback (método específico "torrent"), el fallo se propaga
err := runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps)
// El download falla pero runDownload puede retornar nil (el manager registra el fallo)
// Lo importante es que no haga panic
_ = err
}
func TestRunDownload_Method_Torrent(t *testing.T) {
var capturedTask agent.Task
dl := &capturingTestDownloader{
method: engine.MethodTorrent,
capturedFn: func(t agent.Task) { capturedTask = t },
resultDir: t.TempDir(),
resultFile: "movie.mkv",
resultBytes: make([]byte, 512),
}
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return dl, nil
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
os.WriteFile(filepath.Join(dl.resultDir, dl.resultFile), dl.resultBytes, 0o644) //nolint:errcheck
runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps) //nolint:errcheck
if capturedTask.PreferredMethod != "torrent" {
t.Errorf("PreferredMethod = %q, want torrent", capturedTask.PreferredMethod)
}
}
func TestRunDownload_Method_Debrid(t *testing.T) {
var capturedTask agent.Task
resultDir := t.TempDir()
resultFile := filepath.Join(resultDir, "movie.mkv")
os.WriteFile(resultFile, make([]byte, 512), 0o644) //nolint:errcheck
capFn := func(task agent.Task) { capturedTask = task }
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
// Torrent no disponible: fuerza el uso del método debrid
return &testDownloader{method: engine.MethodTorrent, available: false}, nil
},
newDebridDl: func() engine.Downloader {
// Debrid disponible y captura la tarea
return &capturingTestDownloader{
method: engine.MethodDebrid,
capturedFn: capFn,
resultDir: resultDir,
resultFile: "movie.mkv",
resultBytes: make([]byte, 512),
}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "debrid", deps) //nolint:errcheck
if capturedTask.PreferredMethod != "debrid" {
t.Errorf("PreferredMethod = %q, want debrid", capturedTask.PreferredMethod)
}
}
func TestRunDownload_OutputDirCreated(t *testing.T) {
// Verificar que el dir de salida se crea aunque no exista
downloadDir := filepath.Join(t.TempDir(), "new-subdir", "downloads")
// No crear el directorio — runDownload debe hacerlo
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
// Una vez creado el dir, podemos retornar error para terminar
if _, err := os.Stat(cfg.DataDir); err != nil {
return nil, fmt.Errorf("output dir was not created")
}
return nil, fmt.Errorf("stopping after dir check")
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
// Necesitamos que cfg.Download.Dir apunte a nuestro dir de test
// loadConfig() usará el default, así que testeamos la creación del dir
// Alternativa: verificar que si el dir ya existe, no falla
_ = deps
_ = downloadDir
// Este test documenta la intención aunque no pueda inyectar el dir fácilmente
// sin refactorizar loadConfig(). El comportamiento se testa indirectamente.
t.Skip("requiere inyección de config — comportamiento cubierto por tests de integración")
}
func TestRunDownloadCmd_Args_TooFew(t *testing.T) {
cmd := newDownloadCmd()
// Sin argumentos → cobra debe devolver error
err := cmd.Args(cmd, []string{})
if err == nil {
t.Fatal("expected error for 0 args")
}
}
func TestRunDownloadCmd_Args_TooMany(t *testing.T) {
cmd := newDownloadCmd()
err := cmd.Args(cmd, []string{"hash1", "hash2"})
if err == nil {
t.Fatal("expected error for 2 args")
}
}
func TestRunDownloadCmd_Args_ExactlyOne(t *testing.T) {
cmd := newDownloadCmd()
err := cmd.Args(cmd, []string{"abc123def456abc123def456abc123def456abc1"})
if err != nil {
t.Errorf("unexpected error for 1 arg: %v", err)
}
}
// capturingTestDownloader captura la tarea recibida para verificar los flags.
type capturingTestDownloader struct {
method engine.DownloadMethod
capturedFn func(agent.Task)
resultDir string
resultFile string
resultBytes []byte
}
func (d *capturingTestDownloader) Method() engine.DownloadMethod { return d.method }
func (d *capturingTestDownloader) Available(_ context.Context, _ *engine.Task) (bool, error) {
return true, nil
}
func (d *capturingTestDownloader) Download(_ context.Context, task *engine.Task, _ string, _ chan<- engine.Progress) (*engine.Result, error) {
if d.capturedFn != nil {
d.capturedFn(agent.Task{
ID: task.ID,
PreferredMethod: task.PreferredMethod,
})
}
filePath := filepath.Join(d.resultDir, d.resultFile)
return &engine.Result{
FilePath: filePath,
FileName: d.resultFile,
Method: d.method,
Size: int64(len(d.resultBytes)),
}, nil
}
func (d *capturingTestDownloader) Pause(_ string) error { return nil }
func (d *capturingTestDownloader) Cancel(_ string) error { return nil }
func (d *capturingTestDownloader) Shutdown(_ context.Context) error { return nil }
// TestRunDownload_QuickFail_NoDeadlock verifica que cuando el downloader falla
// rápidamente, runDownload retorna sin deadlock.
func TestRunDownload_QuickFail_NoDeadlock(t *testing.T) {
deps := downloadDeps{
newTorrentDl: func(cfg engine.TorrentConfig) (engine.Downloader, error) {
return &testDownloader{
method: engine.MethodTorrent,
available: true,
err: errors.New("no peers found"),
}, nil
},
newDebridDl: func() engine.Downloader {
return &testDownloader{method: engine.MethodDebrid, available: false}
},
newAgentClient: func(url, key, ua string) *agent.Client {
return agent.NewClient("http://localhost", "", "test")
},
newManager: engine.NewManager,
}
done := make(chan struct{}, 1)
go func() {
runDownloadWithDeps("abc123def456abc123def456abc123def456abc1", "torrent", deps) //nolint:errcheck
done <- struct{}{}
}()
select {
case <-done:
// OK, terminó sin deadlock
case <-time.After(10 * time.Second):
t.Fatal("runDownload did not return within 10s — possible deadlock")
}
}

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. // 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) { func openBrowser(url string) {
if !isSafeBrowserURL(url) {
return
}
var c *exec.Cmd var c *exec.Cmd
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
c = exec.Command("open", url) c = exec.Command("open", "--", url)
case "windows": case "windows":
// rundll32 does not parse switches from positional args.
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default: // linux, freebsd default: // linux, freebsd
c = exec.Command("xdg-open", url) c = exec.Command("xdg-open", url)
@ -22,6 +31,12 @@ func openBrowser(url string) {
_ = c.Start() // fire and forget; best-effort _ = 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. // defaultDownloadDir returns a sensible default download directory.
func defaultDownloadDir() string { func defaultDownloadDir() string {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()

View file

@ -0,0 +1,69 @@
package cmd
import (
"os"
"strings"
"testing"
)
func TestExpandHome(t *testing.T) {
home, _ := os.UserHomeDir()
tests := []struct {
input string
want string
}{
{"~/Documents", home + "/Documents"},
{"~/", home},
{"/absolute/path", "/absolute/path"},
{"relative/path", "relative/path"},
{"", ""},
{"~notexpanded", "~notexpanded"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := expandHome(tt.input)
if got != tt.want {
t.Errorf("expandHome(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
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 == "" {
t.Error("defaultDownloadDir() returned empty string")
}
home, _ := os.UserHomeDir()
if !strings.HasPrefix(dir, home) {
t.Errorf("defaultDownloadDir() = %q, expected to start with home dir %q", dir, home)
}
}

View file

@ -360,18 +360,8 @@ func runInit(apiURLOverride string) error {
fmt.Println() fmt.Println()
// Features summary // Features summary
features := []string{} if line := formatFeatures(resp.Features); line != "" {
if resp.Features.Torrent { cyan.Printf(" Available: %s\n", line)
features = append(features, "Torrent")
}
if resp.Features.Debrid {
features = append(features, "Debrid")
}
if resp.Features.Usenet {
features = append(features, "Usenet")
}
if len(features) > 0 {
cyan.Printf(" Available: %s\n", strings.Join(features, ", "))
} }
if !installDaemon { if !installDaemon {

187
internal/cmd/login.go Normal file
View file

@ -0,0 +1,187 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"runtime"
"strings"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/google/uuid"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
func newLoginCmd() *cobra.Command {
var apiURL string
cmd := &cobra.Command{
Use: "login",
Aliases: []string{"auth"},
Short: "Authenticate with your torrentclaw account",
Long: `Log in to your torrentclaw account by opening the browser or pasting
your API key manually. Use this when your API key has expired, been
revoked, or you want to switch to a different account.
Unlike 'unarr init', this command only updates your authentication
credentials it does not modify your download directory, daemon
settings, or other configuration.`,
Example: ` unarr login
unarr login --api-url https://custom.server.com`,
RunE: func(cmd *cobra.Command, args []string) error {
return runLogin(apiURL)
},
}
cmd.Flags().StringVar(&apiURL, "api-url", "", "API URL override (default: https://torrentclaw.com)")
return cmd
}
func runLogin(apiURLOverride string) error {
if !isTerminal() {
return fmt.Errorf("interactive mode requires a terminal (use UNARR_API_KEY env var instead)")
}
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
dim := color.New(color.FgHiBlack)
fmt.Println()
bold.Println(" unarr login")
fmt.Println()
cfg := loadConfig()
// Determine API URL
apiURL := cfg.Auth.APIURL
if apiURLOverride != "" {
apiURL = apiURLOverride
}
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
// ── Authenticate ────────────────────────────────────────────────
var apiKey string
// Try browser-based auth first
fmt.Println(" Opening browser to connect your account...")
fmt.Println()
browserKey, browserErr := browserAuth(apiURL)
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
apiKey = browserKey
green.Println(" ✓ Connected via browser")
fmt.Println()
} else {
// Fallback to manual API key entry
if browserErr != nil {
dim.Printf(" Could not connect automatically: %s\n", browserErr)
}
fmt.Println(" Paste your API key instead:")
dim.Printf(" (get it from %s/profile?tab=apikey)\n", apiURL)
fmt.Println()
err := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("API Key").
Placeholder("tc_...").
Value(&apiKey).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("API key is required")
}
if !strings.HasPrefix(s, "tc_") {
return fmt.Errorf("API key should start with tc_")
}
return nil
}),
),
).Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Login cancelled.")
return nil
}
return err
}
apiKey = strings.TrimSpace(apiKey)
}
// ── Validate API key ────────────────────────────────────────────
fmt.Print(" Verifying API key... ")
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
agentName = hostname
}
ac := agent.NewClient(apiURL, apiKey, "unarr/"+Version)
resp, err := ac.Register(context.Background(), agent.RegisterRequest{
AgentID: agentID,
Name: agentName,
OS: runtime.GOOS,
Arch: runtime.GOARCH,
Version: Version,
DownloadDir: cfg.Download.Dir,
})
if err != nil {
color.Red("FAILED")
fmt.Println()
return fmt.Errorf("API key validation failed: %w", err)
}
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()
// ── Save config (auth fields only) ──────────────────────────────
cfg.Auth.APIKey = apiKey
cfg.Auth.APIURL = apiURL
cfg.Agent.ID = agentID
cfg.Agent.Name = agentName
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.Println(" ✓ Credentials saved!")
fmt.Printf(" Config: %s\n", configPath)
fmt.Println()
// Features summary
if line := formatFeatures(resp.Features); line != "" {
color.New(color.FgCyan).Printf(" Available: %s\n", line)
fmt.Println()
}
if cfg.Download.Dir == "" {
fmt.Println(" Run " + bold.Sprint("unarr init") + " to complete the setup (download directory, daemon).")
fmt.Println()
}
return nil
}

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

@ -0,0 +1,96 @@
package cmd
import (
"context"
"sync"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// 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 playerSessionRegistryT struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
}
func (r *playerSessionRegistryT) has(sessionID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.cancels[sessionID]
return ok
}
func (r *playerSessionRegistryT) add(sessionID string, cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancels[sessionID] = cancel
}
func (r *playerSessionRegistryT) remove(sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.cancels, sessionID)
}
// 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)
}
playerSessionRegistry.cancels = make(map[string]context.CancelFunc)
playerSessionRegistry.mu.Unlock()
for _, c := range cancels {
c()
}
}
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
// 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}
}
ffmpegPath, errF := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
ffprobePath, errP := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath)
if errF != nil || errP != nil {
return engine.TranscodeRuntime{Disabled: true}
}
hw := engine.HWAccelNone
switch cfg.Download.Transcode.HWAccel {
case "auto":
hw = engine.DetectHWAccel(ctx, ffmpegPath)
case "nvenc":
hw = engine.HWAccelNVENC
case "qsv":
hw = engine.HWAccelQSV
case "vaapi":
hw = engine.HWAccelVAAPI
case "videotoolbox":
hw = engine.HWAccelVideoToolbox
case "none", "":
hw = engine.HWAccelNone
}
return engine.TranscodeRuntime{
FFmpegPath: ffmpegPath,
FFprobePath: ffprobePath,
HWAccel: hw,
Preset: cfg.Download.Transcode.Preset,
VideoBitrate: cfg.Download.Transcode.VideoBitrate,
AudioBitrate: cfg.Download.Transcode.AudioBitrate,
MaxHeight: cfg.Download.Transcode.MaxHeight,
}
}

View file

@ -0,0 +1,176 @@
package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/engine"
)
// newProbeHWAccelCmd reports the hardware-acceleration capabilities the daemon
// 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
// transcodes and no obvious way to diagnose where the regression sits.
func newProbeHWAccelCmd() *cobra.Command {
return &cobra.Command{
Use: "probe-hwaccel",
Short: "Diagnose hardware-acceleration availability",
Long: `Report the hardware-acceleration backends the daemon would pick for
transcoding, plus exactly why each one was kept or rejected.
Checks performed:
- ffmpeg / ffprobe paths
- which HW encoders the ffmpeg binary supports (h264_nvenc, h264_qsv, h264_vaapi)
- whether the matching device files / drivers are actually present
- which backend the daemon would pick today (HWAccelNone means software)
Use this when transcoding feels slow or fails on 4K the most common cause
is a software-only ffmpeg build, not a missing GPU.`,
Example: ` unarr probe-hwaccel`,
RunE: func(cmd *cobra.Command, args []string) error {
return runProbeHWAccel()
},
}
}
func runProbeHWAccel() error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
red := color.New(color.FgRed)
fmt.Println()
bold.Println(" Hardware acceleration probe")
fmt.Println()
// 1. Locate ffmpeg / ffprobe.
ffmpegPath, ffmpegErr := exec.LookPath("ffmpeg")
ffprobePath, ffprobeErr := exec.LookPath("ffprobe")
bold.Println(" Binaries")
if ffmpegErr != nil {
red.Printf(" x ffmpeg not on PATH\n")
fmt.Println()
yellow.Println(" HW probe needs ffmpeg. Install it:")
fmt.Println(" Ubuntu/Debian: sudo apt install ffmpeg")
fmt.Println(" macOS: brew install ffmpeg")
fmt.Println()
return nil
}
green.Printf(" OK ffmpeg %s\n", ffmpegPath)
if ffprobeErr != nil {
yellow.Printf(" ! ffprobe not on PATH (HLS still works, source probing falls back to ffmpeg)\n")
} else {
green.Printf(" OK ffprobe %s\n", ffprobePath)
}
fmt.Println()
// 2. List encoders the ffmpeg binary supports.
bold.Println(" HW encoders compiled in")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders").CombinedOutput()
if err != nil {
red.Printf(" x ffmpeg -encoders failed: %v\n", err)
fmt.Println()
return nil
}
encoders := string(out)
hwEncoders := []struct {
name string
family string
family2 string
}{
{"h264_nvenc", "NVIDIA NVENC", "hevc_nvenc"},
{"h264_qsv", "Intel Quick Sync", "hevc_qsv"},
{"h264_vaapi", "Linux VA-API (Intel/AMD)", "hevc_vaapi"},
{"h264_videotoolbox", "macOS VideoToolbox", "hevc_videotoolbox"},
}
anyHWEncoder := false
for _, e := range hwEncoders {
hasH264 := strings.Contains(encoders, e.name)
hasHEVC := strings.Contains(encoders, e.family2)
if hasH264 || hasHEVC {
anyHWEncoder = true
green.Printf(" OK %s\n", e.family)
if hasH264 {
fmt.Printf(" %s\n", e.name)
}
if hasHEVC {
fmt.Printf(" %s\n", e.family2)
}
}
}
if !anyHWEncoder {
red.Printf(" x No HW encoders compiled in\n")
fmt.Println()
yellow.Println(" Most likely your ffmpeg was built without --enable-nvenc /")
yellow.Println(" --enable-libmfx / --enable-vaapi. Brew's default formula is one")
yellow.Println(" common offender. On Ubuntu, the system package ships with VAAPI")
yellow.Println(" by default and NVENC if you have CUDA installed.")
}
fmt.Println()
// 3. Device-file checks.
bold.Println(" Devices / drivers")
checks := []struct {
path string
desc string
}{
{"/dev/nvidia0", "NVIDIA GPU"},
{"/dev/dri/renderD128", "Linux DRM render node (used by VA-API + QSV)"},
}
for _, c := range checks {
if fileExistsLocal(c.path) {
green.Printf(" OK %s — %s\n", c.path, c.desc)
} else {
yellow.Printf(" - %s — %s (not present)\n", c.path, c.desc)
}
}
if _, err := exec.LookPath("nvidia-smi"); err == nil {
green.Printf(" OK nvidia-smi on PATH\n")
} else {
yellow.Printf(" - nvidia-smi not on PATH\n")
}
if runtime.GOOS == "darwin" {
fmt.Printf(" . macOS host — VideoToolbox available if encoder was compiled in\n")
}
fmt.Println()
// 4. Daemon's actual decision.
engine.ResetHWAccelCache()
pick := engine.DetectHWAccel(ctx, ffmpegPath)
bold.Println(" Daemon would pick")
switch pick {
case engine.HWAccelNone:
red.Printf(" x %s — software libx264 only\n", pick)
fmt.Println()
yellow.Println(" On a slow CPU 1080p will lag and 4K is effectively unwatchable.")
yellow.Println(" Fix: rebuild / reinstall ffmpeg with HW encoder support, then:")
fmt.Println()
fmt.Println(" unarr daemon restart")
default:
green.Printf(" OK %s\n", pick)
fmt.Printf(" encoder: %s (h264) / %s (hevc)\n", pick.FFmpegVideoCodec("h264"), pick.FFmpegVideoCodec("hevc"))
}
fmt.Println()
return nil
}
// fileExistsLocal stats a path. Mirrors engine.fileExists without exporting it.
func fileExistsLocal(path string) bool {
_, err := os.Stat(path)
return err == nil
}

View file

@ -3,12 +3,14 @@
package cmd package cmd
import ( import (
"errors"
"fmt"
"log" "log"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
"github.com/fatih/color"
"github.com/torrentclaw/unarr/internal/agent" "github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
) )
@ -19,7 +21,8 @@ type ReloadableConfig struct {
} }
// startReloadWatcher listens for SIGUSR1 and reloads config. // startReloadWatcher listens for SIGUSR1 and reloads config.
// Only intervals are hot-reloadable (speeds require torrent client restart). // With the sync-based architecture, intervals are fixed (3s watching, 60s idle).
// Hot-reload now mainly serves as a signal to re-read config for future settings.
func startReloadWatcher(rc *ReloadableConfig) { func startReloadWatcher(rc *ReloadableConfig) {
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGUSR1) signal.Notify(sigCh, syscall.SIGUSR1)
@ -28,26 +31,50 @@ func startReloadWatcher(rc *ReloadableConfig) {
for range sigCh { for range sigCh {
log.Println("Received SIGUSR1, reloading config...") log.Println("Received SIGUSR1, reloading config...")
cfg, err := config.Load("") _, err := config.Load("")
if err != nil { if err != nil {
log.Printf("Config reload failed: %v", err) log.Printf("Config reload failed: %v", err)
continue continue
} }
cfg.ApplyEnvOverrides()
// Update poll interval
if d, _ := time.ParseDuration(cfg.Daemon.PollInterval); d > 0 && rc.Daemon.PollTicker != nil {
rc.Daemon.PollTicker.Reset(d)
log.Printf(" Poll interval: %s", d)
}
// Update heartbeat interval
if d, _ := time.ParseDuration(cfg.Daemon.HeartbeatInterval); d > 0 && rc.Daemon.HeartbeatTicker != nil {
rc.Daemon.HeartbeatTicker.Reset(d)
log.Printf(" Heartbeat interval: %s", d)
}
log.Println("Config reloaded successfully") log.Println("Config reloaded successfully")
} }
}() }()
} }
// sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error {
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 {
return fmt.Errorf("find process %d: %w", state.PID, err)
}
if err := p.Signal(syscall.SIGUSR1); err != nil {
return fmt.Errorf("send reload signal to PID %d: %w", state.PID, err)
}
fmt.Println()
color.New(color.FgGreen).Printf(" ✓ Reload signal sent to daemon (PID %d)\n", state.PID)
fmt.Println(" Config will be re-read shortly.")
fmt.Println()
return nil
}
// killPID sends SIGTERM to the given PID for a graceful shutdown.
func killPID(pid int) error {
p, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("find process %d: %w", pid, err)
}
if err := p.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("stop daemon (PID %d): %w", pid, err)
}
color.New(color.FgGreen).Printf(" ✓ Stop signal sent to daemon (PID %d)\n", pid)
fmt.Println()
return nil
}

View file

@ -2,7 +2,15 @@
package cmd package cmd
import "github.com/torrentclaw/unarr/internal/agent" import (
"fmt"
"os"
"os/exec"
"strconv"
"github.com/fatih/color"
"github.com/torrentclaw/unarr/internal/agent"
)
// ReloadableConfig holds a reference to the daemon for hot-reload. // ReloadableConfig holds a reference to the daemon for hot-reload.
type ReloadableConfig struct { type ReloadableConfig struct {
@ -11,3 +19,25 @@ type ReloadableConfig struct {
// startReloadWatcher is a no-op on Windows (no SIGUSR1 support). // startReloadWatcher is a no-op on Windows (no SIGUSR1 support).
func startReloadWatcher(_ *ReloadableConfig) {} func startReloadWatcher(_ *ReloadableConfig) {}
// sendReloadSignal is not supported on Windows; instructs the user to restart instead.
func sendReloadSignal() error {
fmt.Println()
color.New(color.FgYellow).Println(" ⚠ Config reload via signal is not supported on Windows.")
fmt.Println(" Use 'unarr daemon restart' to apply configuration changes.")
fmt.Println()
return nil
}
// killPID stops the daemon process on Windows using taskkill.
func killPID(pid int) error {
cmd := exec.Command("taskkill", "/pid", strconv.Itoa(pid), "/f")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("stop daemon (PID %d): %w", pid, err)
}
color.New(color.FgGreen).Printf(" ✓ Daemon stopped (PID %d)\n", pid)
fmt.Println()
return nil
}

View file

@ -9,6 +9,7 @@ import (
tc "github.com/torrentclaw/go-client" tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry" "github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
) )
var ( var (
@ -24,16 +25,20 @@ var (
func init() { func init() {
rootCmd = &cobra.Command{ rootCmd = &cobra.Command{
Use: "unarr", Use: "unarr",
Short: "unarr — torrent search and management", Version: Version,
Long: `unarr is a powerful terminal tool for torrent search and management. Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
Long: `unarr is a terminal-native client that downloads torrents, debrid links,
Search 30+ torrent sources, inspect torrent quality, discover popular content, and usenet (NZB) all from the same binary. It streams content straight
find streaming providers, and manage your media collection all from your terminal. 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: Get started:
unarr init First-time configuration wizard 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 unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli Documentation: https://torrentclaw.com/cli
@ -42,6 +47,10 @@ Source: https://github.com/torrentclaw/unarr`,
if noColor || os.Getenv("NO_COLOR") != "" { if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true 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, SilenceUsage: true,
SilenceErrors: true, SilenceErrors: true,
@ -50,7 +59,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output // Command groups for organized help output
rootCmd.AddGroup( rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"}, &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: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"}, &cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"}, &cobra.Group{ID: "system", Title: "System & Diagnostics:"},
@ -64,6 +73,8 @@ Source: https://github.com/torrentclaw/unarr`,
// Getting Started // Getting Started
initCmd := newInitCmd() initCmd := newInitCmd()
initCmd.GroupID = "start" initCmd.GroupID = "start"
loginCmd := newLoginCmd()
loginCmd.GroupID = "start"
configCmd := newConfigCmd() configCmd := newConfigCmd()
configCmd.GroupID = "start" configCmd.GroupID = "start"
migrateCmd := newMigrateCmd() migrateCmd := newMigrateCmd()
@ -96,14 +107,22 @@ Source: https://github.com/torrentclaw/unarr`,
statusCmd.GroupID = "daemon" statusCmd.GroupID = "daemon"
daemonCmd := newDaemonCmd() daemonCmd := newDaemonCmd()
daemonCmd.GroupID = "daemon" daemonCmd.GroupID = "daemon"
vpnCmd := newVPNCmd()
vpnCmd.GroupID = "daemon"
funnelCmd := newFunnelCmd()
funnelCmd.GroupID = "daemon"
// System & Diagnostics // System & Diagnostics
statsCmd := newStatsCmd() statsCmd := newStatsCmd()
statsCmd.GroupID = "system" statsCmd.GroupID = "system"
doctorCmd := newDoctorCmd() doctorCmd := newDoctorCmd()
doctorCmd.GroupID = "system" doctorCmd.GroupID = "system"
probeHWAccelCmd := newProbeHWAccelCmd()
probeHWAccelCmd.GroupID = "system"
cleanCmd := newCleanCmd() cleanCmd := newCleanCmd()
cleanCmd.GroupID = "system" cleanCmd.GroupID = "system"
mirrorsCmd := newMirrorsCmd()
mirrorsCmd.GroupID = "system"
selfUpdateCmd := newSelfUpdateCmd() selfUpdateCmd := newSelfUpdateCmd()
selfUpdateCmd.GroupID = "system" selfUpdateCmd.GroupID = "system"
versionCmd := newVersionCmd() versionCmd := newVersionCmd()
@ -118,6 +137,7 @@ Source: https://github.com/torrentclaw/unarr`,
rootCmd.AddCommand( rootCmd.AddCommand(
// Getting Started // Getting Started
initCmd, initCmd,
loginCmd,
configCmd, configCmd,
migrateCmd, migrateCmd,
// Search & Discovery // Search & Discovery
@ -134,22 +154,21 @@ Source: https://github.com/torrentclaw/unarr`,
stopCmd, stopCmd,
statusCmd, statusCmd,
daemonCmd, daemonCmd,
vpnCmd,
funnelCmd,
// System & Diagnostics // System & Diagnostics
statsCmd, statsCmd,
doctorCmd, doctorCmd,
probeHWAccelCmd,
cleanCmd, cleanCmd,
mirrorsCmd,
selfUpdateCmd, selfUpdateCmd,
versionCmd, versionCmd,
completionCmd, completionCmd,
// Library // Library
scanCmd, scanCmd,
// Stubs for future commands // Alias: upgrade → self-update
newStubCmd("upgrade", "Find a better version of a torrent"), newUpgradeCmd(),
newStubCmd("moreseed", "Find same quality with more seeders"),
newStubCmd("compare", "Compare two torrents side by side"),
newStubCmd("add", "Search and add torrents to your client"),
newStubCmd("monitor", "Watch for new episodes of a series"),
newStubCmd("open", "Open content in the browser"),
) )
} }

View file

@ -9,6 +9,7 @@ import (
"sort" "sort"
"strings" "strings"
"syscall" "syscall"
"time"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@ -40,11 +41,16 @@ to see available quality upgrades.`,
} }
if len(args) == 0 { if len(args) == 0 {
cfg := loadConfig() cfg := loadConfig()
if cfg.Library.ScanPath != "" { paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
args = append(args, cfg.Library.ScanPath) if len(paths) == 0 {
} else { return fmt.Errorf("usage: unarr scan <path>\n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'")
return fmt.Errorf("usage: unarr scan <path>\n\nProvide a media folder to scan")
} }
for _, p := range paths {
if err := runScan(p, workers, ffprobe, noSync); err != nil {
return err
}
}
return nil
} }
return runScan(args[0], workers, ffprobe, noSync) return runScan(args[0], workers, ffprobe, noSync)
}, },
@ -165,6 +171,7 @@ func syncToServer(ctx context.Context, cfg config.Config, cache *library.Library
totalSynced := 0 totalSynced := 0
totalMatched := 0 totalMatched := 0
totalRemoved := 0 totalRemoved := 0
syncStartedAt := time.Now().UTC().Format(time.RFC3339)
for i := 0; i < len(items); i += batchSize { for i := 0; i < len(items); i += batchSize {
end := i + batchSize end := i + batchSize
@ -177,9 +184,10 @@ func syncToServer(ctx context.Context, cfg config.Config, cache *library.Library
fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", end, len(items)) fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", end, len(items))
resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{ resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
Items: batch, Items: batch,
ScanPath: cache.Path, ScanPath: cache.Path,
IsLastBatch: isLast, IsLastBatch: isLast,
SyncStartedAt: syncStartedAt,
}) })
if err != nil { if err != nil {
return fmt.Errorf("sync failed: %w", err) return fmt.Errorf("sync failed: %w", err)
@ -233,7 +241,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue continue
} }
res := library.ResolveResolution(item.MediaInfo.Video.Height) res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
if res == "" { if res == "" {
res = "other" res = "other"
} }

View file

@ -3,19 +3,17 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/exec"
"runtime"
"strings" "strings"
"syscall"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/upgrade" "github.com/torrentclaw/unarr/internal/upgrade"
) )
func newSelfUpdateCmd() *cobra.Command { func newSelfUpdateCmd() *cobra.Command {
var force bool var force bool
var allowUnsigned bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "self-update", Use: "self-update",
@ -23,29 +21,35 @@ func newSelfUpdateCmd() *cobra.Command {
Long: `Download and install the latest version of unarr. Long: `Download and install the latest version of unarr.
Checks GitHub for the latest release, verifies the checksum, and Checks GitHub for the latest release, verifies the checksum, and
replaces the current binary. A backup is kept at <binary>.backup.`, replaces the current binary. A backup is kept at <binary>.backup.
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 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 { 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().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 return cmd
} }
func runSelfUpdate(force bool) error { func runSelfUpdate(force, allowUnsigned bool) error {
bold := color.New(color.Bold) bold := color.New(color.Bold)
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
red := color.New(color.FgRed)
fmt.Println() fmt.Println()
bold.Println(" unarr self-update") bold.Println(" unarr self-update")
fmt.Println() fmt.Println()
// Check latest version
fmt.Print(" Checking latest version... ") fmt.Print(" Checking latest version... ")
ctx := context.Background() ctx := context.Background()
latest, err := upgrade.CheckLatest(ctx) latest, err := upgrade.CheckLatest(ctx)
@ -73,6 +77,7 @@ func runSelfUpdate(force bool) error {
upgrader := &upgrade.Upgrader{ upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean, CurrentVersion: currentClean,
AllowUnsigned: allowUnsigned,
OnProgress: func(msg string) { OnProgress: func(msg string) {
fmt.Printf(" %s\n", msg) fmt.Printf(" %s\n", msg)
}, },
@ -89,37 +94,25 @@ func runSelfUpdate(force bool) error {
if result.BackupPath != "" { if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath) fmt.Printf(" Backup: %s\n", result.BackupPath)
} }
fmt.Println()
// If running as daemon, re-exec to restart with new binary // Auto-restart daemon if it is running, otherwise the live process keeps
// For interactive use, just suggest restarting // serving the old version (heartbeat reports old version → web gates
if isRunningAsDaemon() { // features against the wrong version).
fmt.Println(" Restarting daemon with new version...") if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
binPath, err := os.Executable() fmt.Println()
if err != nil { fmt.Printf(" → Daemon running (PID %d), restarting to load new version...\n", state.PID)
return fmt.Errorf("could not determine executable path: %w", err) if err := runDaemonSvcRestart(); err != nil {
fmt.Println()
red.Printf(" ✗ Auto-restart failed: %v\n", err)
fmt.Println(" The new binary is on disk but the daemon is still running the old version.")
fmt.Println(" Run manually: unarr daemon restart")
fmt.Println(" (If the daemon runs under a different user/session, restart it there.)")
fmt.Println()
return nil
} }
execErr := syscall.Exec(binPath, os.Args, os.Environ()) green.Println(" ✓ Daemon restarted")
if execErr != nil && runtime.GOOS == "windows" {
// Windows doesn't support syscall.Exec — start new process
proc := exec.Command(binPath, os.Args[1:]...)
proc.Stdout = os.Stdout
proc.Stderr = os.Stderr
proc.Stdin = os.Stdin
return proc.Start()
}
return execErr
} }
fmt.Println()
return nil return nil
} }
func isRunningAsDaemon() bool {
// Simple heuristic: check if "start" was in the original args
for _, arg := range os.Args {
if arg == "start" {
return true
}
}
return false
}

View file

@ -1,20 +1,27 @@
package cmd package cmd
import ( import (
"context"
"errors"
"fmt" "fmt"
"runtime"
"strings"
"time"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/upgrade"
) )
func newStatusCmd() *cobra.Command { func newStatusCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "status", Use: "status",
Short: "Show daemon status and active downloads", Short: "Show daemon status, configuration, and update availability",
Long: `Display the current state of the daemon, active downloads, and recent activity. Long: `Display the current state of unarr: version, configuration, daemon status,
disk usage, and whether an update is available.
Shows the configured agent name, download directory, and preferred method. When the daemon is running, also displays uptime, active downloads, and stats.`,
When the daemon is running, also displays active downloads and their progress.`,
Example: ` unarr status`, Example: ` unarr status`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runStatus() return runStatus()
@ -25,27 +32,232 @@ When the daemon is running, also displays active downloads and their progress.`,
func runStatus() error { func runStatus() error {
bold := color.New(color.Bold) bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack) dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
cyan := color.New(color.FgCyan)
fmt.Println() fmt.Println()
bold.Printf(" unarr %s\n", Version) bold.Printf(" unarr %s\n", Version)
dim.Printf(" %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Println() fmt.Println()
cfg := loadConfig() cfg := loadConfig()
// ── Configuration ──
if cfg.Auth.APIKey == "" { if cfg.Auth.APIKey == "" {
dim.Println(" Not configured. Run 'unarr init' first.") yellow.Println(" ⚠ Not configured. Run 'unarr init' first.")
fmt.Println() fmt.Println()
return nil return nil
} }
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, cfg.Agent.ID[:8]+"...") // ── Account (async fetch) ──
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir) type accountResult struct {
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod) user agent.UserInfo
err error
}
accountCh := make(chan accountResult, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ac := newAgentClientFromConfig(cfg, "unarr/"+Version)
resp, err := ac.Register(ctx, agent.RegisterRequest{
AgentID: cfg.Agent.ID,
Name: cfg.Agent.Name,
Version: Version,
})
if err != nil {
accountCh <- accountResult{err: err}
return
}
accountCh <- accountResult{user: resp.User}
}()
cyan.Println(" Account")
ar := <-accountCh
if ar.err != nil {
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)
planColor := dim
if ar.user.IsPro {
planColor = green
}
planColor.Printf(" Plan: %s\n", strings.ToUpper(ar.user.Plan))
}
fmt.Println() fmt.Println()
dim.Println(" Daemon not running. Start with 'unarr start'") cyan.Println(" Configuration")
dim.Println(" (Live status will be shown here when daemon is running)") agentID := cfg.Agent.ID
if len(agentID) > 8 {
agentID = agentID[:8] + "..."
}
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, agentID)
fmt.Printf(" Server: %s\n", cfg.Auth.APIURL)
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
if cfg.Download.PreferredQuality != "" {
fmt.Printf(" Quality: %s\n", cfg.Download.PreferredQuality)
}
fmt.Printf(" Concurrent: %d\n", cfg.Download.MaxConcurrent)
if cfg.Organize.Enabled {
fmt.Printf(" Organize: on")
if cfg.Organize.MoviesDir != "" {
fmt.Printf(" (movies: %s", cfg.Organize.MoviesDir)
if cfg.Organize.TVShowsDir != "" {
fmt.Printf(", tv: %s", cfg.Organize.TVShowsDir)
}
fmt.Print(")")
}
fmt.Println()
}
fmt.Println()
// ── Disk ──
if cfg.Download.Dir != "" {
if free, total, err := agent.DiskInfo(cfg.Download.Dir); err == nil && total > 0 {
usedPct := float64(total-free) / float64(total) * 100
cyan.Println(" Disk")
fmt.Printf(" Free: %s / %s (%.0f%% used)\n", formatBytes(free), formatBytes(total), usedPct)
if dirSize, err := agent.DirSize(cfg.Download.Dir); err == nil {
fmt.Printf(" Downloads: %s\n", formatBytes(dirSize))
}
if usedPct > 90 {
yellow.Println(" ⚠ Low disk space!")
}
fmt.Println()
}
}
// ── Daemon ──
cyan.Println(" Daemon")
state := agent.ReadState()
if state != nil && isDaemonAlive(state) {
green.Printf(" Status: running (PID %d)\n", state.PID)
fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt)))
fmt.Printf(" Last beat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat)))
fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks)
fmt.Printf(" Completed: %d\n", state.CompletedCount)
if state.FailedCount > 0 {
fmt.Printf(" Failed: %d\n", state.FailedCount)
}
if state.TotalDownloaded > 0 {
fmt.Printf(" Downloaded: %s\n", formatBytes(state.TotalDownloaded))
}
if len(state.MethodStats) > 0 {
parts := make([]string, 0, len(state.MethodStats))
for method, count := range state.MethodStats {
parts = append(parts, fmt.Sprintf("%s:%d", method, count))
}
fmt.Printf(" Methods: %s\n", strings.Join(parts, ", "))
}
} else {
dim.Println(" Status: stopped")
dim.Println(" Start with: unarr start")
}
fmt.Println()
// ── Update check (cached: instant if <1h, otherwise async 3s) ──
type versionResult struct {
version string
fromCache bool
err error
}
versionCh := make(chan versionResult, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
v, cached, err := upgrade.CheckLatestCached(ctx)
versionCh <- versionResult{v, cached, err}
}()
cyan.Println(" Update")
fmt.Print(" Checking... ")
vr := <-versionCh
if vr.err != nil {
dim.Println("could not check (offline?)")
} else {
currentClean := strings.TrimPrefix(Version, "v")
if currentClean == vr.version {
green.Printf("✓ up to date (v%s)\n", vr.version)
} else {
yellow.Printf("v%s available! ", vr.version)
fmt.Printf("Run: unarr upgrade\n")
}
}
fmt.Println() fmt.Println()
return nil return nil
} }
// isDaemonAlive checks if the daemon process from the state file is still running.
// Guards against PID reuse by also checking heartbeat recency.
func isDaemonAlive(state *agent.DaemonState) bool {
if state.PID == 0 {
return false
}
// Reject stale state: if last heartbeat is older than 2 minutes, the daemon
// likely crashed and the PID may have been reused by another process.
if !state.LastHeartbeat.IsZero() && time.Since(state.LastHeartbeat) > 2*time.Minute {
return false
}
return agent.IsProcessAlive(state.PID)
}
// formatFeatures returns a comma-separated list of available features, or "".
func formatFeatures(f agent.FeatureFlags) string {
var features []string
if f.Torrent {
features = append(features, "Torrent")
}
if f.Debrid {
features = append(features, "Debrid")
}
if f.Usenet {
features = append(features, "Usenet")
}
return strings.Join(features, ", ")
}
// formatBytes formats bytes into human-readable string.
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatDuration formats a duration into a compact human-readable string.
func formatDuration(d time.Duration) string {
if d < 0 {
return "0s"
}
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
}
if d < 24*time.Hour {
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
}
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
return fmt.Sprintf("%dd %dh", days, hours)
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"os/exec"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"strings" "strings"
@ -17,6 +18,20 @@ import (
"github.com/torrentclaw/unarr/internal/ui" "github.com/torrentclaw/unarr/internal/ui"
) )
// streamDeps agrupa las funciones constructoras usadas por runStream.
// Pueden sobreescribirse en tests para inyectar mocks.
type streamDeps struct {
newStreamEngine func(cfg engine.StreamConfig) (*engine.StreamEngine, error)
newStreamServer func(port int) *engine.StreamServer
openPlayer func(url, override string) (string, *exec.Cmd, error)
}
var defaultStreamDeps = streamDeps{
newStreamEngine: engine.NewStreamEngine,
newStreamServer: engine.NewStreamServer,
openPlayer: engine.OpenPlayer,
}
func newStreamCmd() *cobra.Command { func newStreamCmd() *cobra.Command {
var ( var (
port int port int
@ -56,6 +71,10 @@ download directory (or system temp if not configured).`,
} }
func runStream(input string, port int, noOpen bool, playerCmd string) error { func runStream(input string, port int, noOpen bool, playerCmd string) error {
return runStreamWithDeps(input, port, noOpen, playerCmd, defaultStreamDeps)
}
func runStreamWithDeps(input string, port int, noOpen bool, playerCmd string, deps streamDeps) error {
cfg := loadConfig() cfg := loadConfig()
bold := color.New(color.Bold) bold := color.New(color.Bold)
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
@ -83,7 +102,7 @@ func runStream(input string, port int, noOpen bool, playerCmd string) error {
} }
// Create engine // Create engine
eng, err := engine.NewStreamEngine(engine.StreamConfig{ eng, err := deps.newStreamEngine(engine.StreamConfig{
DataDir: dataDir, DataDir: dataDir,
Port: port, Port: port,
MetaTimeout: 60 * time.Second, MetaTimeout: 60 * time.Second,
@ -127,14 +146,14 @@ func runStream(input string, port int, noOpen bool, playerCmd string) error {
} }
// Start HTTP server // Start HTTP server
srv := engine.NewStreamServer(eng, port) srv := deps.newStreamServer(port)
streamURL, err := srv.Start(ctx) if err := srv.Listen(ctx); err != nil {
if err != nil {
eng.Shutdown(context.Background()) eng.Shutdown(context.Background())
return fmt.Errorf("start server: %w", err) return fmt.Errorf("start server: %w", err)
} }
srv.SetFile(eng, "cli-stream")
fmt.Printf(" URL: %s\n", streamURL) fmt.Printf(" URL: %s\n", srv.URL())
fmt.Println() fmt.Println()
// Buffer before opening player // Buffer before opening player
@ -159,15 +178,15 @@ func runStream(input string, port int, noOpen bool, playerCmd string) error {
// Open player // Open player
if !noOpen { if !noOpen {
playerName, _, openErr := engine.OpenPlayer(streamURL, playerCmd) playerName, _, openErr := deps.openPlayer(srv.URL(), playerCmd)
if openErr != nil { if openErr != nil {
yellow.Printf(" Could not open player: %s\n", openErr) yellow.Printf(" Could not open player: %s\n", openErr)
fmt.Printf(" Open this URL in your player: %s\n", streamURL) fmt.Printf(" Open this URL in your player: %s\n", srv.URL())
} else { } else {
green.Printf(" Opened in %s\n", playerName) green.Printf(" Opened in %s\n", playerName)
} }
} else { } else {
fmt.Printf(" Open this URL in your player: %s\n", streamURL) fmt.Printf(" Open this URL in your player: %s\n", srv.URL())
} }
fmt.Println() fmt.Println()

View file

@ -14,34 +14,80 @@ import (
"github.com/torrentclaw/unarr/internal/ui" "github.com/torrentclaw/unarr/internal/ui"
) )
// streamRegistry tracks active stream tasks and servers for cancellation. const streamIdleTimeout = 30 * time.Minute
// startIdleGuard monitors the persistent stream server and clears the file after inactivity.
func startIdleGuard(ctx context.Context, srv *engine.StreamServer) {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if srv.HasFile() && srv.IdleSince() > streamIdleTimeout {
taskID := srv.CurrentTaskID()
short := taskID
if len(short) > 8 {
short = short[:8]
}
log.Printf("[%s] stream idle timeout (%v no HTTP requests), clearing file", short, streamIdleTimeout)
cancelStreamContexts()
srv.ClearFile()
}
}
}
}
// streamRegistry tracks active stream goroutine contexts for cancellation.
// There is only ONE persistent StreamServer — no per-task servers.
var streamRegistry = struct { var streamRegistry = struct {
mu sync.Mutex mu sync.Mutex
cancels map[string]context.CancelFunc cancels map[string]context.CancelFunc
servers map[string]*engine.StreamServer // servers for active download streams
}{ }{
cancels: make(map[string]context.CancelFunc), cancels: make(map[string]context.CancelFunc),
servers: make(map[string]*engine.StreamServer),
} }
// cancelStreamTask cancels a running stream task and shuts down any stream server. // cancelStreamContexts cancels all active stream goroutines (download engines, etc.).
func cancelStreamTask(taskID string) { // Does NOT touch the persistent server — call srv.ClearFile() separately if needed.
func cancelStreamContexts() {
streamRegistry.mu.Lock() streamRegistry.mu.Lock()
if cancel, ok := streamRegistry.cancels[taskID]; ok { cancels := make(map[string]context.CancelFunc, len(streamRegistry.cancels))
cancel() for k, v := range streamRegistry.cancels {
delete(streamRegistry.cancels, taskID) cancels[k] = v
} delete(streamRegistry.cancels, k)
if srv, ok := streamRegistry.servers[taskID]; ok {
srv.Shutdown(context.Background())
delete(streamRegistry.servers, taskID)
} }
streamRegistry.mu.Unlock() streamRegistry.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
} }
// handleStreamTask manages a streaming task lifecycle outside the Manager. // isStreamingTask returns true if there is an active stream goroutine for the given task.
// It creates a StreamEngine, buffers, starts an HTTP server, and reports func isStreamingTask(taskID string) bool {
// progress until the task is cancelled or the download completes. streamRegistry.mu.Lock()
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config) { defer streamRegistry.mu.Unlock()
_, ok := streamRegistry.cancels[taskID]
return ok
}
// cancelStreamTask cancels a specific stream goroutine.
func cancelStreamTask(taskID string) {
streamRegistry.mu.Lock()
cancel, ok := streamRegistry.cancels[taskID]
delete(streamRegistry.cancels, taskID)
streamRegistry.mu.Unlock()
if ok {
cancel()
}
}
// handleStreamTask manages a streaming task lifecycle for active torrent downloads.
// It creates a StreamEngine, buffers, sets the file on the persistent server,
// and reports progress until the task is cancelled or the download completes.
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config, agentClient *agent.Client, srv *engine.StreamServer) {
ctx, cancel := context.WithCancel(parentCtx) ctx, cancel := context.WithCancel(parentCtx)
defer cancel() defer cancel()
@ -53,6 +99,10 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
streamRegistry.mu.Lock() streamRegistry.mu.Lock()
delete(streamRegistry.cancels, at.ID) delete(streamRegistry.cancels, at.ID)
streamRegistry.mu.Unlock() streamRegistry.mu.Unlock()
// Clear file from persistent server if we're still the current task
if srv.CurrentTaskID() == at.ID {
srv.ClearFile()
}
}() }()
task := engine.NewTaskFromAgent(at) task := engine.NewTaskFromAgent(at)
@ -93,31 +143,37 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
return return
} }
// 4. Start HTTP server // 4. Set file on the persistent stream server (instant, no port binding)
srv := engine.NewStreamServer(eng, 0) srv.SetFile(eng, at.ID)
streamURL, err := srv.Start(ctx) task.StreamURL = srv.URLsJSON()
if err != nil { log.Printf("[%s] stream ready: %s (url: %s)", at.ID[:8], eng.FileName(), srv.URL())
task.ErrorMessage = "start HTTP server: " + err.Error()
task.Transition(engine.StatusFailed) // Pre-descargar los últimos 5 MB del archivo para que el moov atom (MP4)
return // o el seekhead (MKV) estén disponibles cuando VLC los pida al abrir el
// stream. Sin esto, VLC busca el final del archivo, el lector bloquea
// esperando piezas no descargadas, y el resultado es pantalla negra en
// redes remotas donde la latencia amplifica el efecto.
eng.PrioritizeTail(ctx, 5*1024*1024)
// 5. Start watch progress reporter
if agentClient != nil {
watchReporter := engine.NewWatchReporter(agentClient, srv, at.ID)
go watchReporter.Run(ctx)
} }
defer srv.Shutdown(context.Background())
// 5. Report stream URL — the reporter will send this to the web // 6. Progress loop until download completes or cancelled
task.StreamURL = streamURL
log.Printf("[%s] stream ready: %s", at.ID[:8], streamURL)
// 6. Progress loop
eng.StartProgressLoop(ctx) eng.StartProgressLoop(ctx)
ticker := time.NewTicker(3 * time.Second) progressTicker := time.NewTicker(3 * time.Second)
defer ticker.Stop() defer progressTicker.Stop()
completed := false
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Printf("[%s] stream stopped", at.ID[:8]) log.Printf("[%s] stream stopped", at.ID[:8])
return return
case <-ticker.C:
case <-progressTicker.C:
p := eng.Progress() p := eng.Progress()
task.UpdateProgress(engine.Progress{ task.UpdateProgress(engine.Progress{
DownloadedBytes: p.DownloadedBytes, DownloadedBytes: p.DownloadedBytes,
@ -129,7 +185,7 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
}) })
// Terminal progress // Terminal progress
if p.TotalBytes > 0 { if !completed && p.TotalBytes > 0 {
pct := int(float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100) pct := int(float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100)
fmt.Fprintf(os.Stderr, "\r[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d", fmt.Fprintf(os.Stderr, "\r[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d",
at.ID[:8], pct, at.ID[:8], pct,
@ -137,20 +193,11 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
p.Peers, p.Seeds) p.Peers, p.Seeds)
} }
if p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 { if !completed && p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
fmt.Fprint(os.Stderr, "\r\033[2K") // clear progress line fmt.Fprint(os.Stderr, "\r\033[2K")
task.Transition(engine.StatusCompleted) task.Transition(engine.StatusCompleted)
log.Printf("[%s] stream download complete, server stays up for 30m or until cancelled", at.ID[:8]) log.Printf("[%s] stream download complete, server stays up until idle (30m)", at.ID[:8])
// Keep HTTP server running so the player can finish reading. completed = true
// Auto-shutdown after 30 minutes of idle to prevent resource leaks.
idleTimer := time.NewTimer(30 * time.Minute)
defer idleTimer.Stop()
select {
case <-ctx.Done():
case <-idleTimer.C:
log.Printf("[%s] stream idle timeout (30m), shutting down", at.ID[:8])
}
return
} }
} }
} }

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

@ -0,0 +1,165 @@
package cmd
import (
"fmt"
"os/exec"
"strings"
"testing"
"github.com/torrentclaw/unarr/internal/engine"
)
// --- Tests de validación de entrada para runStream ---
func TestRunStream_EmptyInput(t *testing.T) {
err := runStream("", 0, true, "")
if err == nil {
t.Fatal("expected error for empty input")
}
}
func TestRunStream_InvalidInput_NotHashNotMagnet(t *testing.T) {
err := runStream("The Matrix 1999", 0, true, "")
if err == nil {
t.Fatal("expected error for plain text input")
}
if !strings.Contains(err.Error(), "invalid") {
t.Errorf("error = %q, want 'invalid' in message", err.Error())
}
}
func TestRunStream_InvalidInput_TooShort(t *testing.T) {
err := runStream("abc123", 0, true, "")
if err == nil {
t.Fatal("expected error for hash too short")
}
}
func TestRunStream_ValidHash_PassesValidation(t *testing.T) {
// Un hash válido debe pasar la validación y llegar a newStreamEngine.
// Inyectamos un engine que falla inmediatamente para no necesitar red.
deps := streamDeps{
newStreamEngine: func(cfg engine.StreamConfig) (*engine.StreamEngine, error) {
return nil, fmt.Errorf("test: stopping after validation")
},
newStreamServer: engine.NewStreamServer,
openPlayer: func(url, override string) (string, *exec.Cmd, error) {
return "", nil, nil
},
}
err := runStreamWithDeps("abc123def456abc123def456abc123def456abc1", 0, true, "", deps)
if err == nil {
t.Fatal("expected error from newStreamEngine mock")
}
// El error debe venir del engine, no de validación
if strings.Contains(err.Error(), "invalid input") {
t.Errorf("error = %q — should not be a validation error, hash is valid", err.Error())
}
if !strings.Contains(err.Error(), "create stream engine") {
t.Errorf("error = %q — expected 'create stream engine' from engine creation failure", err.Error())
}
}
func TestRunStream_MagnetURI_PassesValidation(t *testing.T) {
deps := streamDeps{
newStreamEngine: func(cfg engine.StreamConfig) (*engine.StreamEngine, error) {
return nil, fmt.Errorf("test: stopping after validation")
},
newStreamServer: engine.NewStreamServer,
openPlayer: func(url, override string) (string, *exec.Cmd, error) {
return "", nil, nil
},
}
magnet := "magnet:?xt=urn:btih:abc123def456abc123def456abc123def456abc1&dn=Test"
err := runStreamWithDeps(magnet, 0, true, "", deps)
if err == nil {
t.Fatal("expected error from newStreamEngine mock")
}
if strings.Contains(err.Error(), "invalid input") {
t.Errorf("magnet URI should be valid, got validation error: %v", err)
}
}
func TestRunStream_EngineCreationFails(t *testing.T) {
deps := streamDeps{
newStreamEngine: func(cfg engine.StreamConfig) (*engine.StreamEngine, error) {
return nil, fmt.Errorf("failed to create torrent client")
},
newStreamServer: engine.NewStreamServer,
openPlayer: func(url, override string) (string, *exec.Cmd, error) {
return "", nil, nil
},
}
err := runStreamWithDeps("abc123def456abc123def456abc123def456abc1", 0, true, "", deps)
if err == nil {
t.Fatal("expected error when engine creation fails")
}
if !strings.Contains(err.Error(), "create stream engine") {
t.Errorf("error = %q, want 'create stream engine' in message", err.Error())
}
}
func TestRunStreamCmd_Args_TooFew(t *testing.T) {
cmd := newStreamCmd()
err := cmd.Args(cmd, []string{})
if err == nil {
t.Fatal("expected error for 0 args")
}
}
func TestRunStreamCmd_Args_TooMany(t *testing.T) {
cmd := newStreamCmd()
err := cmd.Args(cmd, []string{"hash1", "hash2"})
if err == nil {
t.Fatal("expected error for 2 args")
}
}
func TestRunStreamCmd_Args_ExactlyOne(t *testing.T) {
cmd := newStreamCmd()
err := cmd.Args(cmd, []string{"abc123def456abc123def456abc123def456abc1"})
if err != nil {
t.Errorf("unexpected error for 1 arg: %v", err)
}
}
func TestRunStream_PartialMagnet_Prefix(t *testing.T) {
// "magnet:" sin hash es válido para el parser (tiene el prefijo magnet:)
// pero no tiene infoHash — debe pasar la validación de input
deps := streamDeps{
newStreamEngine: func(cfg engine.StreamConfig) (*engine.StreamEngine, error) {
return nil, fmt.Errorf("test stop")
},
newStreamServer: engine.NewStreamServer,
openPlayer: func(url, override string) (string, *exec.Cmd, error) { return "", nil, nil },
}
// "magnet:" sin btih se trata como magnet (HasPrefix("magnet:") == true)
// por lo que pasa la validación de input
err := runStreamWithDeps("magnet:", 0, true, "", deps)
// Debe llegar al engine (validación OK) o fallar con error de engine
_ = err // no verificamos el contenido exacto, solo que no haya panic
}
func TestRunStream_NoOpen_DoesNotCallOpenPlayer(t *testing.T) {
playerCalled := false
deps := streamDeps{
newStreamEngine: func(cfg engine.StreamConfig) (*engine.StreamEngine, error) {
return nil, fmt.Errorf("test: stopping early")
},
newStreamServer: engine.NewStreamServer,
openPlayer: func(url, override string) (string, *exec.Cmd, error) {
playerCalled = true
return "mpv", nil, nil
},
}
// noOpen=true → openPlayer no debe llamarse
runStreamWithDeps("abc123def456abc123def456abc123def456abc1", 0, true, "", deps) //nolint:errcheck
if playerCalled {
t.Error("openPlayer should NOT be called when noOpen=true")
}
}

View file

@ -1,22 +0,0 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
)
func newStubCmd(name, short string) *cobra.Command {
return &cobra.Command{
Use: name,
Short: short + " (coming soon)",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println()
color.New(color.FgYellow).Printf(" ⚠️ '%s' is coming in a future release.\n", name)
fmt.Println()
fmt.Println(" Follow progress at: https://github.com/torrentclaw/unarr")
fmt.Println()
},
}
}

33
internal/cmd/upgrade.go Normal file
View file

@ -0,0 +1,33 @@
package cmd
import (
"github.com/spf13/cobra"
)
// 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",
Aliases: []string{"update"},
Short: "Update unarr to the latest version",
Long: `Download and install the latest version of unarr.
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 --allow-unsigned`,
RunE: func(cmd *cobra.Command, args []string) error {
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 package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time. // Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.3.4-dev" 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,7 +26,11 @@ type Config struct {
type AuthConfig struct { type AuthConfig struct {
APIKey string `toml:"api_key"` APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"` APIURL string `toml:"api_url"`
WSURL string `toml:"ws_url"` // optional, derived from api_url if empty // 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 { type AgentConfig struct {
@ -35,15 +39,88 @@ type AgentConfig struct {
} }
type DownloadConfig struct { type DownloadConfig struct {
Dir string `toml:"dir"` Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"` PreferredMethod string `toml:"preferred_method"`
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"` MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") 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) 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)
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
// when source codecs aren't browser-decodable (HEVC, AV1, AC3, DTS, etc.).
// 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 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
} }
type OrganizeConfig struct { type OrganizeConfig struct {
@ -53,10 +130,28 @@ type OrganizeConfig struct {
} }
type DaemonConfig struct { type DaemonConfig struct {
PollInterval string `toml:"poll_interval"` StatusInterval string `toml:"status_interval"`
HeartbeatInterval string `toml:"heartbeat_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 { type NotificationsConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
} }
@ -71,28 +166,66 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8) Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true) 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") ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
} }
// Default returns a Config with sensible defaults. // Default returns a Config with sensible defaults. Used both for fresh
// installs (no config file yet) and as the baseline for Load — fields not
// present in the user's TOML keep their Default() value.
func Default() Config { func Default() Config {
return Config{ return Config{
Auth: AuthConfig{ Auth: AuthConfig{
APIURL: "https://torrentclaw.com", 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{ Download: DownloadConfig{
PreferredMethod: "auto", PreferredMethod: "auto",
MaxConcurrent: 3, MaxConcurrent: 3,
StreamPort: 11818,
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
// 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{ Organize: OrganizeConfig{
Enabled: true, Enabled: true,
}, },
Daemon: DaemonConfig{
PollInterval: "30s",
HeartbeatInterval: "30s",
},
Notifications: NotificationsConfig{ Notifications: NotificationsConfig{
Enabled: true, Enabled: true,
}, },
@ -126,25 +259,67 @@ func Load(path string) (Config, error) {
return cfg, fmt.Errorf("read config: %w", err) return cfg, fmt.Errorf("read config: %w", err)
} }
if err := toml.Unmarshal(data, &cfg); err != nil { meta, err := toml.Decode(string(data), &cfg)
if err != nil {
return cfg, fmt.Errorf("parse config: %w", err) return cfg, fmt.Errorf("parse config: %w", err)
} }
// Re-apply defaults for zero values that should have defaults applyDefaults(&cfg, meta)
if cfg.Auth.APIURL == "" { return cfg, nil
}
// applyDefaults fills in sensible defaults for keys that the user did not
// define in the TOML file. We use MetaData (rather than zero-value checks) so
// that explicitly setting a field to its zero value (e.g. `enabled = false`)
// is respected — only truly missing keys get defaulted. This lets a fresh
// install work out of the box for streaming without forcing every user to
// edit the TOML, while still letting power users disable features.
func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("auth", "api_url") {
cfg.Auth.APIURL = "https://torrentclaw.com" cfg.Auth.APIURL = "https://torrentclaw.com"
} }
if cfg.Download.PreferredMethod == "" { if !meta.IsDefined("auth", "mirrors") {
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
}
if !meta.IsDefined("downloads", "preferred_method") {
cfg.Download.PreferredMethod = "auto" cfg.Download.PreferredMethod = "auto"
} }
if cfg.Download.MaxConcurrent == 0 { if !meta.IsDefined("downloads", "max_concurrent") {
cfg.Download.MaxConcurrent = 3 cfg.Download.MaxConcurrent = 3
} }
if cfg.General.Country == "" { if !meta.IsDefined("downloads", "stream_port") {
cfg.Download.StreamPort = 11818
}
if !meta.IsDefined("general", "country") {
cfg.General.Country = "US" cfg.General.Country = "US"
} }
return cfg, nil if !meta.IsDefined("downloads", "transcode", "enabled") {
cfg.Download.Transcode.Enabled = true
}
if !meta.IsDefined("downloads", "transcode", "hw_accel") {
cfg.Download.Transcode.HWAccel = "auto"
}
if !meta.IsDefined("downloads", "transcode", "preset") {
// 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"
}
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. // Save writes config to the default or specified path using atomic write.

View file

@ -21,8 +21,8 @@ func TestDefault(t *testing.T) {
if cfg.General.Country != "US" { if cfg.General.Country != "US" {
t.Errorf("default Country = %q, want US", cfg.General.Country) t.Errorf("default Country = %q, want US", cfg.General.Country)
} }
if cfg.Daemon.HeartbeatInterval != "30s" { if cfg.Daemon.StatusInterval != "" {
t.Errorf("default HeartbeatInterval = %q, want 30s", cfg.Daemon.HeartbeatInterval) t.Errorf("default StatusInterval = %q, want empty", cfg.Daemon.StatusInterval)
} }
} }
@ -190,6 +190,62 @@ func TestParseSpeed(t *testing.T) {
} }
} }
func TestLoadMinimalTOMLAppliesStreamingDefaults(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// Minimal config — only auth + agent. Nothing about webrtc / transcode.
os.WriteFile(path, []byte(`[auth]
api_key = "tc_minimal"
[agent]
id = "agent-uuid"
name = "Test"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
// Transcode should be on by default.
if !cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled should default to true when [downloads.transcode] is absent")
}
if cfg.Download.Transcode.HWAccel != "auto" {
t.Errorf("Transcode.HWAccel = %q, want auto", cfg.Download.Transcode.HWAccel)
}
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)
}
}
func TestLoadRespectsExplicitlyDisabledStreaming(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// 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)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled = true, want false (user explicitly disabled)")
}
}
func TestLoadInvalidTOML(t *testing.T) { func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml") path := filepath.Join(tmp, "config.toml")

View file

@ -10,6 +10,8 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"github.com/torrentclaw/unarr/internal/agent"
) )
// httpClient is used for debrid HTTPS downloads with a reasonable header timeout. // httpClient is used for debrid HTTPS downloads with a reasonable header timeout.
@ -19,13 +21,6 @@ var httpClient = &http.Client{
}, },
} }
func shortID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
// DebridDownloader downloads files via HTTPS direct URLs resolved by the server. // DebridDownloader downloads files via HTTPS direct URLs resolved by the server.
// The server handles all debrid provider interaction; this downloader only needs // The server handles all debrid provider interaction; this downloader only needs
// a plain HTTPS URL to fetch. // a plain HTTPS URL to fetch.
@ -129,7 +124,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
var serverSize int64 var serverSize int64
if _, err := fmt.Sscanf(cr, "bytes */%d", &serverSize); err == nil && serverSize > 0 && existingSize != serverSize { if _, err := fmt.Sscanf(cr, "bytes */%d", &serverSize); err == nil && serverSize > 0 && existingSize != serverSize {
// Local file size doesn't match server — re-download from scratch // Local file size doesn't match server — re-download from scratch
log.Printf("[%s] local size %s != server size %s, re-downloading", shortID(task.ID), formatBytes(existingSize), formatBytes(serverSize)) log.Printf("[%s] local size %s != server size %s, re-downloading", agent.ShortID(task.ID), formatBytes(existingSize), formatBytes(serverSize))
resp.Body.Close() resp.Body.Close()
req2, err := http.NewRequestWithContext(dlCtx, http.MethodGet, task.DirectURL, nil) req2, err := http.NewRequestWithContext(dlCtx, http.MethodGet, task.DirectURL, nil)
if err != nil { if err != nil {
@ -149,7 +144,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
break // continue to download loop break // continue to download loop
} }
} }
log.Printf("[%s] file already complete: %s (%s)", shortID(task.ID), fileName, formatBytes(existingSize)) log.Printf("[%s] file already complete: %s (%s)", agent.ShortID(task.ID), fileName, formatBytes(existingSize))
return &Result{ return &Result{
FilePath: destPath, FilePath: destPath,
FileName: fileName, FileName: fileName,
@ -166,10 +161,10 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
var flags int var flags int
if startOffset > 0 { if startOffset > 0 {
flags = os.O_WRONLY | os.O_APPEND flags = os.O_WRONLY | os.O_APPEND
log.Printf("[%s] resuming debrid download at %s: %s", shortID(task.ID), formatBytes(startOffset), fileName) log.Printf("[%s] resuming debrid download at %s: %s", agent.ShortID(task.ID), formatBytes(startOffset), fileName)
} else { } else {
flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
log.Printf("[%s] starting debrid download: %s", shortID(task.ID), fileName) log.Printf("[%s] starting debrid download: %s", agent.ShortID(task.ID), fileName)
} }
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
@ -223,7 +218,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
} }
log.Printf("[%s] %d%% — %s/%s @ %s/s (debrid)", log.Printf("[%s] %d%% — %s/%s @ %s/s (debrid)",
shortID(task.ID), pct, agent.ShortID(task.ID), pct,
formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed)) formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed))
p := Progress{ p := Progress{
@ -252,7 +247,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
} }
} }
log.Printf("[%s] debrid download complete: %s (%s)", shortID(task.ID), fileName, formatBytes(downloaded)) log.Printf("[%s] debrid download complete: %s (%s)", agent.ShortID(task.ID), fileName, formatBytes(downloaded))
return &Result{ return &Result{
FilePath: destPath, FilePath: destPath,
@ -271,7 +266,7 @@ func (d *DebridDownloader) Pause(taskID string) error {
if ok { if ok {
cancel() cancel()
log.Printf("[%s] debrid download paused (file kept for resume)", shortID(taskID)) log.Printf("[%s] debrid download paused (file kept for resume)", agent.ShortID(taskID))
} }
return nil return nil
} }
@ -285,7 +280,7 @@ func (d *DebridDownloader) Cancel(taskID string) error {
if ok { if ok {
cancel() cancel()
log.Printf("[%s] debrid download cancelled", shortID(taskID)) log.Printf("[%s] debrid download cancelled", agent.ShortID(taskID))
} }
return nil return nil
} }

1508
internal/engine/hls.go Normal file

File diff suppressed because it is too large Load diff

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)
}
}
}

273
internal/engine/hwaccel.go Normal file
View file

@ -0,0 +1,273 @@
package engine
import (
"context"
"os"
"os/exec"
"runtime"
"strings"
"sync"
)
// HWAccel identifies a hardware-accelerated ffmpeg encoder family.
type HWAccel string
const (
HWAccelNone HWAccel = "none"
HWAccelNVENC HWAccel = "nvenc" // NVIDIA — h264_nvenc / hevc_nvenc
HWAccelQSV HWAccel = "qsv" // Intel Quick Sync — h264_qsv / hevc_qsv
HWAccelVAAPI HWAccel = "vaapi" // Linux open-source — h264_vaapi / hevc_vaapi
HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS — h264_videotoolbox
)
var (
hwOnce sync.Once
hwCache HWAccel
)
// DetectHWAccel returns the most capable hardware encoder available on this
// host, or HWAccelNone if software-only. Cached after first call — adding /
// removing a GPU at runtime is rare and the cost of probing isn't free.
func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
hwOnce.Do(func() {
hwCache = detectHWAccelFresh(ctx, ffmpegPath)
})
return hwCache
}
// ResetHWAccelCache clears the singleton — only used in tests.
func ResetHWAccelCache() {
hwOnce = sync.Once{}
hwCache = ""
}
func detectHWAccelFresh(ctx context.Context, ffmpegPath string) HWAccel {
if ffmpegPath == "" {
return HWAccelNone
}
encoders := listFFmpegEncoders(ctx, ffmpegPath)
if encoders == "" {
return HWAccelNone
}
// macOS — VideoToolbox is always available on Apple Silicon + recent Intel.
if runtime.GOOS == "darwin" && strings.Contains(encoders, "h264_videotoolbox") {
return HWAccelVideoToolbox
}
// NVIDIA — encoder presence + a CUDA-capable device. We rely on the
// existence of the device file rather than running nvidia-smi to keep
// startup quick on hosts without nvidia tooling.
if strings.Contains(encoders, "h264_nvenc") &&
(fileExists("/dev/nvidia0") || hasNvidiaDriver()) {
return HWAccelNVENC
}
// Intel Quick Sync — needs /dev/dri (also used by VA-API). Distinguish by
// checking whether the QSV-specific encoder is built in.
if strings.Contains(encoders, "h264_qsv") && fileExists("/dev/dri/renderD128") {
return HWAccelQSV
}
// Linux generic VA-API — works on Intel + AMD with mesa drivers.
if strings.Contains(encoders, "h264_vaapi") && fileExists("/dev/dri/renderD128") {
return HWAccelVAAPI
}
return HWAccelNone
}
func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders")
out, err := cmd.CombinedOutput()
if err != nil {
return ""
}
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
}
func hasNvidiaDriver() bool {
// Cheap proxy — if the user has nvidia-smi on PATH they presumably also
// have a working driver / runtime libraries.
_, err := exec.LookPath("nvidia-smi")
return err == nil
}
// FFmpegVideoCodec returns the encoder name to pass to `-c:v` for the
// requested HW accel + target (h264 or hevc).
func (h HWAccel) FFmpegVideoCodec(target string) string {
target = strings.ToLower(target)
switch h {
case HWAccelNVENC:
if target == "hevc" {
return "hevc_nvenc"
}
return "h264_nvenc"
case HWAccelQSV:
if target == "hevc" {
return "hevc_qsv"
}
return "h264_qsv"
case HWAccelVAAPI:
if target == "hevc" {
return "hevc_vaapi"
}
return "h264_vaapi"
case HWAccelVideoToolbox:
if target == "hevc" {
return "hevc_videotoolbox"
}
return "h264_videotoolbox"
default:
// Software fallback. libx264 ships with every ffmpeg build.
return "libx264"
}
}
// 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:
// Unknown source — pick a level that covers up to 4K so we never
// re-introduce the silent-failure mode that motivated this helper.
return "5.1"
case height <= 480:
return "3.1"
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:
return "5.1"
default:
// 4K @ 60 fps and 8K all fall under 6.x.
return "6.0"
}
}

View file

@ -0,0 +1,156 @@
package engine
import (
"strings"
"testing"
)
func TestHWAccelFFmpegVideoCodec(t *testing.T) {
cases := []struct {
hw HWAccel
target string
want string
}{
{HWAccelNone, "h264", "libx264"},
{HWAccelNone, "hevc", "libx264"},
{HWAccelNVENC, "h264", "h264_nvenc"},
{HWAccelNVENC, "hevc", "hevc_nvenc"},
{HWAccelQSV, "h264", "h264_qsv"},
{HWAccelQSV, "hevc", "hevc_qsv"},
{HWAccelVAAPI, "h264", "h264_vaapi"},
{HWAccelVAAPI, "hevc", "hevc_vaapi"},
{HWAccelVideoToolbox, "h264", "h264_videotoolbox"},
{HWAccelVideoToolbox, "hevc", "hevc_videotoolbox"},
}
for _, tc := range cases {
if got := tc.hw.FFmpegVideoCodec(tc.target); got != tc.want {
t.Errorf("%s.FFmpegVideoCodec(%q) = %q want %q", tc.hw, tc.target, got, tc.want)
}
}
}
func TestDetectHWAccelEmptyPathReturnsNone(t *testing.T) {
ResetHWAccelCache()
if got := detectHWAccelFresh(t.Context(), ""); got != HWAccelNone {
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

@ -28,6 +28,15 @@ type Manager struct {
sem chan struct{} sem chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
// OnTaskDone is called after a task completes or fails (slot freed).
// Used by the daemon to trigger an immediate sync.
OnTaskDone func()
// recentlyFinished holds tasks that completed/failed since the last sync read.
// The sync goroutine reads and clears this to include final states in the next sync.
recentMu sync.Mutex
recentFinished []agent.TaskState
} }
// NewManager creates a download manager. // NewManager creates a download manager.
@ -67,7 +76,7 @@ func (m *Manager) Submit(ctx context.Context, at agent.Task) {
// Force start: bypass semaphore (like Transmission's "Force Start") // Force start: bypass semaphore (like Transmission's "Force Start")
if at.ForceStart { if at.ForceStart {
log.Printf("[%s] force start: bypassing queue", task.ID[:8]) log.Printf("[%s] force start: bypassing queue", agent.ShortID(task.ID))
m.wg.Add(1) m.wg.Add(1)
go func() { go func() {
defer m.wg.Done() defer m.wg.Done()
@ -88,7 +97,12 @@ func (m *Manager) Submit(ctx context.Context, at agent.Task) {
m.wg.Add(1) m.wg.Add(1)
go func() { go func() {
defer m.wg.Done() defer m.wg.Done()
defer func() { <-m.sem }() defer func() {
<-m.sem
if m.OnTaskDone != nil {
m.OnTaskDone()
}
}()
defer taskCancel() defer taskCancel()
m.processTask(taskCtx, task) m.processTask(taskCtx, task)
}() }()
@ -99,6 +113,11 @@ func (m *Manager) HasCapacity() bool {
return len(m.sem) < cap(m.sem) return len(m.sem) < cap(m.sem)
} }
// FreeSlots returns the number of available download slots.
func (m *Manager) FreeSlots() int {
return cap(m.sem) - len(m.sem)
}
// ActiveCount returns the number of in-progress downloads. // ActiveCount returns the number of in-progress downloads.
func (m *Manager) ActiveCount() int { func (m *Manager) ActiveCount() int {
m.activeMu.RLock() m.activeMu.RLock()
@ -113,6 +132,17 @@ func (m *Manager) GetTask(taskID string) *Task {
return m.active[taskID] return m.active[taskID]
} }
// ActiveTaskIDs returns the IDs of all in-progress tasks.
func (m *Manager) ActiveTaskIDs() []string {
m.activeMu.RLock()
defer m.activeMu.RUnlock()
ids := make([]string, 0, len(m.active))
for id := range m.active {
ids = append(ids, id)
}
return ids
}
// ActiveTasks returns a snapshot of all active tasks. // ActiveTasks returns a snapshot of all active tasks.
func (m *Manager) ActiveTasks() []*Task { func (m *Manager) ActiveTasks() []*Task {
m.activeMu.RLock() m.activeMu.RLock()
@ -124,6 +154,37 @@ func (m *Manager) ActiveTasks() []*Task {
return tasks return tasks
} }
// TaskStates returns the current state of all active tasks plus any recently
// finished tasks that haven't been synced yet. Called by the sync goroutine.
func (m *Manager) TaskStates() []agent.TaskState {
// Collect active tasks
m.activeMu.RLock()
states := make([]agent.TaskState, 0, len(m.active))
for _, t := range m.active {
states = append(states, agent.TaskStateFromUpdate(t.ToStatusUpdate()))
}
m.activeMu.RUnlock()
// Drain recently finished tasks (consumed once per sync)
m.recentMu.Lock()
states = append(states, m.recentFinished...)
m.recentFinished = nil
m.recentMu.Unlock()
return states
}
// recordFinished stores a completed/failed task for the next sync cycle.
func (m *Manager) recordFinished(update agent.StatusUpdate) {
m.recentMu.Lock()
defer m.recentMu.Unlock()
m.recentFinished = append(m.recentFinished, agent.TaskStateFromUpdate(update))
// Keep bounded
if len(m.recentFinished) > 20 {
m.recentFinished = m.recentFinished[len(m.recentFinished)-20:]
}
}
// CancelTask cancels an active download by task ID (keeps partial files). // CancelTask cancels an active download by task ID (keeps partial files).
func (m *Manager) CancelTask(taskID string) { func (m *Manager) CancelTask(taskID string) {
m.activeMu.RLock() m.activeMu.RLock()
@ -150,7 +211,7 @@ func (m *Manager) CancelTask(taskID string) {
task.mu.Unlock() task.mu.Unlock()
task.Transition(StatusCancelled) task.Transition(StatusCancelled)
log.Printf("[%s] cancelled: %s", taskID[:8], task.Title) log.Printf("[%s] cancelled: %s", agent.ShortID(taskID), task.Title)
} }
// PauseTask pauses an active download (keeps partial files for resume). // PauseTask pauses an active download (keeps partial files for resume).
@ -173,7 +234,7 @@ func (m *Manager) PauseTask(taskID string) {
} }
task.Transition(StatusCancelled) // will be re-created as pending by server task.Transition(StatusCancelled) // will be re-created as pending by server
log.Printf("[%s] paused: %s", taskID[:8], task.Title) log.Printf("[%s] paused: %s", agent.ShortID(taskID), task.Title)
} }
// CancelAndDeleteFiles cancels a download and removes its files from disk. // CancelAndDeleteFiles cancels a download and removes its files from disk.
@ -200,7 +261,7 @@ func (m *Manager) CancelAndDeleteFiles(taskID string) {
task.mu.Unlock() task.mu.Unlock()
task.Transition(StatusCancelled) task.Transition(StatusCancelled)
log.Printf("[%s] cancelled + files deleted: %s", taskID[:8], task.Title) log.Printf("[%s] cancelled + files deleted: %s", agent.ShortID(taskID), task.Title)
} }
// Wait blocks until all active downloads finish. // Wait blocks until all active downloads finish.
@ -261,7 +322,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
} }
task.ResolvedMethod = method task.ResolvedMethod = method
log.Printf("[%s] resolved method: %s", task.ID[:8], method) log.Printf("[%s] resolved method: %s", agent.ShortID(task.ID), method)
// 2. Download // 2. Download
if err := task.Transition(StatusDownloading); err != nil { if err := task.Transition(StatusDownloading); err != nil {
@ -285,7 +346,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
if err != nil { if err != nil {
// Try fallback // Try fallback
if tryFallback(task, m.downloaders) { if tryFallback(task, m.downloaders) {
log.Printf("[%s] %s failed, trying fallback: %v", task.ID[:8], method, err) log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err)
if err := task.Transition(StatusResolving); err == nil { if err := task.Transition(StatusResolving); err == nil {
m.processTaskRetry(ctx, task) m.processTaskRetry(ctx, task)
return return
@ -295,61 +356,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
return return
} }
// 3. Verify m.finalize(ctx, task, result)
if err := task.Transition(StatusVerifying); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error())
return
}
// 4. Organize
if err := task.Transition(StatusOrganizing); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
finalPath, err := organize(result, task, m.cfg.Organize)
if err != nil {
log.Printf("[%s] organize warning: %v (keeping in download dir)", task.ID[:8], err)
finalPath = result.FilePath
}
task.mu.Lock()
task.FilePath = finalPath
task.mu.Unlock()
// 4b. Handle upgrade replacement (mode = "upgrade")
if task.ReplacePath != "" {
backupDir := "" // uses default ~/.local/share/unarr/replaced/
if err := replaceFile(task.ReplacePath, finalPath, backupDir); err != nil {
log.Printf("[%s] replace warning: %v (keeping new file at %s)", task.ID[:8], err, finalPath)
} else {
task.mu.Lock()
task.FilePath = task.ReplacePath
task.mu.Unlock()
log.Printf("[%s] upgraded: replaced %s", task.ID[:8], task.ReplacePath)
}
}
// 5. Complete
if method == MethodTorrent && m.cfg.Organize.Enabled {
// Could add seeding here in the future
}
if err := task.Transition(StatusCompleted); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
log.Printf("[%s] completed: %s -> %s", task.ID[:8], task.Title, finalPath)
if m.cfg.Notifications {
desktopNotify("Download complete", task.Title)
}
m.reporter.ReportFinal(ctx, task)
} }
// processTaskRetry handles fallback after a method failure. // processTaskRetry handles fallback after a method failure.
@ -361,7 +368,7 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
} }
task.ResolvedMethod = method task.ResolvedMethod = method
log.Printf("[%s] fallback to: %s", task.ID[:8], method) log.Printf("[%s] fallback to: %s", agent.ShortID(task.ID), method)
if err := task.Transition(StatusDownloading); err != nil { if err := task.Transition(StatusDownloading); err != nil {
m.fail(ctx, task, "transition error: "+err.Error()) m.fail(ctx, task, "transition error: "+err.Error())
@ -383,15 +390,31 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
return return
} }
// Verify + Organize + Complete (same as processTask) m.finalize(ctx, task, result)
task.Transition(StatusVerifying) }
// finalize runs verify → organize → upgrade replacement → complete for a downloaded task.
func (m *Manager) finalize(ctx context.Context, task *Task, result *Result) {
// Verify
if err := task.Transition(StatusVerifying); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
if err := verify(result); err != nil { if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error()) m.fail(ctx, task, "verification failed: "+err.Error())
return return
} }
task.Transition(StatusOrganizing) // Organize
finalPath, _ := organize(result, task, m.cfg.Organize) if err := task.Transition(StatusOrganizing); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
finalPath, err := organize(result, task, m.cfg.Organize)
if err != nil {
log.Printf("[%s] organize warning: %v (keeping in download dir)", agent.ShortID(task.ID), err)
finalPath = result.FilePath
}
if finalPath == "" { if finalPath == "" {
finalPath = result.FilePath finalPath = result.FilePath
} }
@ -399,8 +422,29 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
task.FilePath = finalPath task.FilePath = finalPath
task.mu.Unlock() task.mu.Unlock()
task.Transition(StatusCompleted) // Handle upgrade replacement (mode = "upgrade")
log.Printf("[%s] completed (fallback): %s -> %s", task.ID[:8], task.Title, finalPath) if task.ReplacePath != "" {
backupDir := "" // uses default ~/.local/share/unarr/replaced/
if err := replaceFile(task.ReplacePath, finalPath, backupDir); err != nil {
log.Printf("[%s] replace warning: %v (keeping new file at %s)", agent.ShortID(task.ID), err, finalPath)
} else {
task.mu.Lock()
task.FilePath = task.ReplacePath
task.mu.Unlock()
log.Printf("[%s] upgraded: replaced %s", agent.ShortID(task.ID), task.ReplacePath)
}
}
// Complete
if err := task.Transition(StatusCompleted); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
return
}
log.Printf("[%s] completed: %s -> %s", agent.ShortID(task.ID), task.Title, finalPath)
if m.cfg.Notifications {
desktopNotify("Download complete", task.Title)
}
m.recordFinished(task.ToStatusUpdate())
m.reporter.ReportFinal(ctx, task) m.reporter.ReportFinal(ctx, task)
} }
@ -409,9 +453,10 @@ func (m *Manager) fail(ctx context.Context, task *Task, msg string) {
task.ErrorMessage = msg task.ErrorMessage = msg
task.mu.Unlock() task.mu.Unlock()
task.Transition(StatusFailed) task.Transition(StatusFailed)
log.Printf("[%s] FAILED: %s — %s", task.ID[:8], task.Title, msg) log.Printf("[%s] FAILED: %s — %s", agent.ShortID(task.ID), task.Title, msg)
if m.cfg.Notifications { if m.cfg.Notifications {
desktopNotify("Download failed", task.Title+": "+msg) desktopNotify("Download failed", task.Title+": "+msg)
} }
m.recordFinished(task.ToStatusUpdate())
m.reporter.ReportFinal(ctx, task) m.reporter.ReportFinal(ctx, task)
} }

View file

@ -0,0 +1,601 @@
package engine
import (
"context"
"fmt"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// errorMockDownloader siempre falla en Download para simular fallo de método.
type errorMockDownloader struct {
method DownloadMethod
err error
}
func (m *errorMockDownloader) Method() DownloadMethod { return m.method }
func (m *errorMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *errorMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
if m.err != nil {
return nil, m.err
}
return nil, fmt.Errorf("simulated download failure for %s", m.method)
}
func (m *errorMockDownloader) Pause(_ string) error { return nil }
func (m *errorMockDownloader) Cancel(_ string) error { return nil }
func (m *errorMockDownloader) Shutdown(_ context.Context) error { return nil }
// makeProgressReporter crea un ProgressReporter con mock de reporter para tests de integración.
func makeProgressReporter() *ProgressReporter {
reporter := &mockStatusReporter{}
return &ProgressReporter{
reporter: reporter,
interval: 100 * time.Millisecond,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
}
// TestManagerPipeline_FullSuccess verifica el pipeline completo:
// submit → download → verify → complete con archivo real en disco.
func TestManagerPipeline_FullSuccess(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 2048), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodTorrent,
Size: 2048,
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "integration-full-123456",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Test Movie",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
}
// TestManagerPipeline_Fallback_TorrentFails_DebridSucceeds verifica que cuando
// torrent falla en modo "auto", el manager hace fallback a debrid.
func TestManagerPipeline_Fallback_TorrentFails_DebridSucceeds(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 2048), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
// Torrent siempre falla
torrentDl := &errorMockDownloader{method: MethodTorrent}
// Debrid tiene éxito
debridDl := &resultMockDownloader{
method: MethodDebrid,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodDebrid,
Size: 2048,
},
}
// Debrid debe declararse disponible — usamos mockDownloader para eso
debridAvailDl := struct {
*errorMockDownloader
*resultMockDownloader
}{torrentDl, debridDl}
_ = debridAvailDl // unused, kept for clarity
// Un mock que es available=true y retorna resultado exitoso
type debridFullMock struct {
resultMockDownloader
}
debridFull := &debridFullMock{
resultMockDownloader: resultMockDownloader{
method: MethodDebrid,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodDebrid,
Size: 2048,
},
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, torrentDl, debridFull)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
// PreferredMethod: "auto" es necesario para que tryFallback funcione
task := agent.Task{
ID: "fallback-test-123456789",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Fallback Movie",
PreferredMethod: "auto",
}
mgr.Submit(ctx, task)
mgr.Wait()
// Si llegamos aquí sin timeout, el fallback funcionó (torrent falló, debrid tuvo éxito)
}
// TestManagerPipeline_AllMethodsFail verifica que cuando todos los downloaders
// fallan, la tarea termina en estado failed.
func TestManagerPipeline_AllMethodsFail(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
torrentDl := &errorMockDownloader{method: MethodTorrent, err: fmt.Errorf("no peers")}
// En modo "torrent" específico no hay fallback
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, torrentDl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "fail-all-123456789012",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Failing Download",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
// Si llegamos aquí, el manager manejó el fallo sin panic ni deadlock
}
// TestManagerPipeline_MultiConcurrent verifica que múltiples descargas concurrentes
// completan todas correctamente.
func TestManagerPipeline_MultiConcurrent(t *testing.T) {
dir := t.TempDir()
const numTasks = 3
// Crear archivos para cada tarea
files := make([]string, numTasks)
for i := 0; i < numTasks; i++ {
files[i] = filepath.Join(dir, fmt.Sprintf("movie%d.mkv", i))
if err := os.WriteFile(files[i], make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
}
var submitCount atomic.Int32
pr := makeProgressReporter()
// Usar un mock que devuelve archivos distintos por tarea
dl := &multiResultMockDownloader{dir: dir, files: files}
mgr := NewManager(ManagerConfig{
MaxConcurrent: numTasks,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
go pr.Run(ctx)
for i := 0; i < numTasks; i++ {
submitCount.Add(1)
task := agent.Task{
ID: fmt.Sprintf("concurrent-task-%02d-123456", i),
InfoHash: fmt.Sprintf("abc%037d", i), // 40 hex chars
Title: fmt.Sprintf("Movie %d", i),
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
}
mgr.Wait()
if submitCount.Load() != int32(numTasks) {
t.Errorf("submitted %d tasks, want %d", submitCount.Load(), numTasks)
}
}
// multiResultMockDownloader devuelve archivos distintos según el orden de llamadas.
type multiResultMockDownloader struct {
dir string
files []string
callCount atomic.Int32
}
func (m *multiResultMockDownloader) Method() DownloadMethod { return MethodTorrent }
func (m *multiResultMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *multiResultMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
idx := int(m.callCount.Add(1)) - 1
if idx >= len(m.files) {
return nil, fmt.Errorf("too many calls to multiResultMockDownloader")
}
return &Result{
FilePath: m.files[idx],
FileName: filepath.Base(m.files[idx]),
Method: MethodTorrent,
Size: 1024,
}, nil
}
func (m *multiResultMockDownloader) Pause(_ string) error { return nil }
func (m *multiResultMockDownloader) Cancel(_ string) error { return nil }
func (m *multiResultMockDownloader) Shutdown(_ context.Context) error { return nil }
// TestManagerPipeline_CancelTaskMidDownload verifica que CancelTask() durante una
// descarga activa libera el slot y no produce deadlock.
func TestManagerPipeline_CancelTaskMidDownload(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
dl := &slowMockDownloader{method: MethodTorrent}
const taskID = "cancel-mid-test-12345"
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: taskID,
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Cancel Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
// Esperar a que la tarea esté activa
time.Sleep(100 * time.Millisecond)
// Cancelar la tarea específica (cancela su contexto interno)
mgr.CancelTask(taskID)
done := make(chan struct{})
go func() {
mgr.Wait()
close(done)
}()
select {
case <-done:
// OK — manager terminó limpiamente tras CancelTask
case <-time.After(5 * time.Second):
t.Error("Manager.Wait() timed out after CancelTask — possible deadlock")
}
}
// TestManagerPipeline_OnTaskDone_Called verifica que el callback OnTaskDone
// se llama exactamente una vez cuando una tarea completa.
func TestManagerPipeline_OnTaskDone_Called(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{FilePath: filePath, FileName: "movie.mkv", Method: MethodTorrent, Size: 1024},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, dl)
var callCount atomic.Int32
mgr.OnTaskDone = func() {
callCount.Add(1)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "ontaskdone-test-123456",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Done Callback Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
if callCount.Load() != 1 {
t.Errorf("OnTaskDone called %d times, want 1", callCount.Load())
}
}
// TestManagerPipeline_RecentFinished_DrainedOnSync verifica que TaskStates()
// incluye tareas recientemente finalizadas y las limpia en la siguiente llamada.
func TestManagerPipeline_RecentFinished_DrainedOnSync(t *testing.T) {
dir := t.TempDir()
filePath := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{FilePath: filePath, FileName: "movie.mkv", Method: MethodTorrent, Size: 1024},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "recent-finished-12345",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Recent Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
// Primera llamada a TaskStates() debe incluir la tarea finalizada
states := mgr.TaskStates()
// La tarea se eliminó del mapa active, pero debe estar en recentFinished
foundRecent := false
for _, s := range states {
if s.TaskID == task.ID {
foundRecent = true
break
}
}
if !foundRecent {
t.Error("TaskStates() should include recently finished task in first call")
}
// Segunda llamada: recentFinished debe estar vacío (ya se drenó)
states2 := mgr.TaskStates()
for _, s := range states2 {
if s.TaskID == task.ID {
t.Error("TaskStates() should NOT include finished task in second call (should be drained)")
break
}
}
}
// TestManagerPipeline_ForceStart_BypassesSemaphore verifica que ForceStart=true
// permite iniciar descargas aunque el semáforo esté lleno.
func TestManagerPipeline_ForceStart_BypassesSemaphore(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
// slowMock bloqueará el semáforo
slowDl := &slowMockDownloader{method: MethodTorrent}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1, // semáforo de 1
OutputDir: dir,
}, pr, slowDl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go pr.Run(ctx)
// Primera tarea: llena el semáforo
task1 := agent.Task{
ID: "force-start-slow-12345",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Slow Task",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task1)
// Pequeña pausa para que task1 adquiera el semáforo
time.Sleep(50 * time.Millisecond)
// Segunda tarea con ForceStart=true: debe empezar aunque semáforo lleno
filePath := filepath.Join(dir, "force.mkv")
if err := os.WriteFile(filePath, make([]byte, 512), 0o644); err != nil {
t.Fatal(err)
}
// Para ForceStart necesitamos un downloader que tenga éxito inmediato
// Usar resultMockDownloader pero ForceStart necesita el mismo downloader registrado
// Modificamos el test: verificar que ActiveCount() > MaxConcurrent con ForceStart
task2 := agent.Task{
ID: "force-start-fast-12345",
InfoHash: "def456abc123def456abc123def456abc123def4",
Title: "Force Task",
PreferredMethod: "torrent",
ForceStart: true,
}
mgr.Submit(ctx, task2)
// Verificar que hay más tareas activas que el límite del semáforo
time.Sleep(50 * time.Millisecond)
active := mgr.ActiveCount()
if active < 1 {
t.Errorf("expected at least 1 active task with ForceStart, got %d", active)
}
cancel() // terminar las tareas lentas
mgr.Wait()
}
// TestManagerPipeline_Organize_MoviesDir verifica que cuando organize está
// habilitado y ContentType es "movie", el archivo se mueve al directorio correcto.
func TestManagerPipeline_Organize_MoviesDir(t *testing.T) {
downloadDir := t.TempDir()
moviesDir := t.TempDir()
filePath := filepath.Join(downloadDir, "movie.mkv")
if err := os.WriteFile(filePath, make([]byte, 1024), 0o644); err != nil {
t.Fatal(err)
}
pr := makeProgressReporter()
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodTorrent,
Size: 1024,
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 1,
OutputDir: downloadDir,
Organize: OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
OutputDir: downloadDir,
},
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "organize-test-1234567",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "The Matrix 1999",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
mgr.Wait()
// El archivo debe haberse movido a moviesDir (o seguir en downloadDir si hay error de organización)
// Lo que nos importa es que no haya crash
}
// TestManagerPipeline_Shutdown_GracefulWithActiveDownloads verifica que Shutdown()
// espera a que terminen las descargas activas antes de salir.
func TestManagerPipeline_Shutdown_GracefulWithActiveDownloads(t *testing.T) {
dir := t.TempDir()
pr := makeProgressReporter()
// Downloader que tarda un poco pero termina
dl := &timedResultMockDownloader{
method: MethodTorrent,
delay: 100 * time.Millisecond,
dir: dir,
content: make([]byte, 512),
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go pr.Run(ctx)
task := agent.Task{
ID: "shutdown-graceful-123",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Graceful Test",
PreferredMethod: "torrent",
}
mgr.Submit(ctx, task)
// Dar tiempo a que la tarea empiece
time.Sleep(20 * time.Millisecond)
// Shutdown con timeout suficiente para que la tarea termine
shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutCancel()
start := time.Now()
mgr.Shutdown(shutCtx)
elapsed := time.Since(start)
if elapsed > 4*time.Second {
t.Errorf("Shutdown took too long: %v", elapsed)
}
}
// timedResultMockDownloader simula una descarga que tarda un tiempo específico.
type timedResultMockDownloader struct {
method DownloadMethod
delay time.Duration
dir string
content []byte
}
func (m *timedResultMockDownloader) Method() DownloadMethod { return m.method }
func (m *timedResultMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *timedResultMockDownloader) Download(ctx context.Context, task *Task, outputDir string, _ chan<- Progress) (*Result, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(m.delay):
}
filePath := filepath.Join(outputDir, "timed.mkv")
if err := os.WriteFile(filePath, m.content, 0o644); err != nil {
return nil, err
}
return &Result{
FilePath: filePath,
FileName: "timed.mkv",
Method: m.method,
Size: int64(len(m.content)),
}, nil
}
func (m *timedResultMockDownloader) Pause(_ string) error { return nil }
func (m *timedResultMockDownloader) Cancel(_ string) error { return nil }
func (m *timedResultMockDownloader) Shutdown(_ context.Context) error { return nil }
// TestManagerPipeline_FreeSlots verifica que FreeSlots() refleja el número
// correcto de slots disponibles.
func TestManagerPipeline_FreeSlots(t *testing.T) {
pr := makeProgressReporter()
mgr := NewManager(ManagerConfig{MaxConcurrent: 3}, pr)
if slots := mgr.FreeSlots(); slots != 3 {
t.Errorf("FreeSlots() = %d, want 3 when empty", slots)
}
}

View file

@ -2,6 +2,7 @@ package engine
import ( import (
"context" "context"
"os"
"testing" "testing"
"time" "time"
@ -83,3 +84,223 @@ func TestManagerShutdown(t *testing.T) {
mgr.Shutdown(ctx) mgr.Shutdown(ctx)
// Should not hang // Should not hang
} }
func TestManagerDefaultConcurrency(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 0}, reporter)
if cap(mgr.sem) != 3 {
t.Errorf("default MaxConcurrent should be 3, got %d", cap(mgr.sem))
}
}
func TestManagerGetTask(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 2}, reporter)
// No task added
if task := mgr.GetTask("nonexistent"); task != nil {
t.Error("expected nil for nonexistent task")
}
}
func TestManagerActiveTasks(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 2}, reporter)
tasks := mgr.ActiveTasks()
if len(tasks) != 0 {
t.Errorf("expected 0 active tasks, got %d", len(tasks))
}
}
func TestManagerSubmitCompletesWithValidFile(t *testing.T) {
dir := t.TempDir()
// Create a file that verify() will accept
filePath := dir + "/movie.mkv"
os.WriteFile(filePath, make([]byte, 1024), 0o644)
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: 100 * time.Millisecond,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
dl := &resultMockDownloader{
method: MethodTorrent,
result: &Result{
FilePath: filePath,
FileName: "movie.mkv",
Method: MethodTorrent,
Size: 1024,
},
}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: dir,
}, pr, dl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go pr.Run(ctx)
mgr.Submit(ctx, agent.Task{
ID: "task-complete-test1",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Test Movie",
PreferredMethod: "torrent",
})
mgr.Wait()
cancel()
// Task should have completed successfully
// (we can't check directly since it's removed from active map after processing)
}
func TestManagerCancelTask(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &slowMockDownloader{method: MethodTorrent}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go reporter.Run(ctx)
mgr.Submit(ctx, agent.Task{
ID: "task-cancel-test12",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Cancel Me",
PreferredMethod: "torrent",
})
// Give it time to start
time.Sleep(100 * time.Millisecond)
mgr.CancelTask("task-cancel-test12")
mgr.Wait()
}
func TestManagerPauseTask(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &slowMockDownloader{method: MethodTorrent}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go reporter.Run(ctx)
mgr.Submit(ctx, agent.Task{
ID: "task-pause-test123",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Pause Me",
PreferredMethod: "torrent",
})
time.Sleep(100 * time.Millisecond)
mgr.PauseTask("task-pause-test123")
mgr.Wait()
}
func TestManagerCancelAndDeleteFiles(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
dl := &slowMockDownloader{method: MethodTorrent}
mgr := NewManager(ManagerConfig{
MaxConcurrent: 2,
OutputDir: t.TempDir(),
}, reporter, dl)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
go reporter.Run(ctx)
mgr.Submit(ctx, agent.Task{
ID: "task-delfile-test12",
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Delete Me",
PreferredMethod: "torrent",
})
time.Sleep(100 * time.Millisecond)
mgr.CancelAndDeleteFiles("task-delfile-test12")
mgr.Wait()
}
func TestManagerCancelNonexistent(t *testing.T) {
reporter := NewProgressReporter(
agent.NewClient("http://localhost", "test", "test"),
1*time.Second,
)
mgr := NewManager(ManagerConfig{MaxConcurrent: 2}, reporter)
// Should not panic
mgr.CancelTask("nonexistent")
mgr.PauseTask("nonexistent")
mgr.CancelAndDeleteFiles("nonexistent")
}
// resultMockDownloader returns a configurable result
type resultMockDownloader struct {
method DownloadMethod
result *Result
}
func (m *resultMockDownloader) Method() DownloadMethod { return m.method }
func (m *resultMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *resultMockDownloader) Download(_ context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
return m.result, nil
}
func (m *resultMockDownloader) Pause(_ string) error { return nil }
func (m *resultMockDownloader) Cancel(_ string) error { return nil }
func (m *resultMockDownloader) Shutdown(_ context.Context) error { return nil }
// slowMockDownloader blocks until context is cancelled
type slowMockDownloader struct {
method DownloadMethod
}
func (m *slowMockDownloader) Method() DownloadMethod { return m.method }
func (m *slowMockDownloader) Available(_ context.Context, _ *Task) (bool, error) {
return true, nil
}
func (m *slowMockDownloader) Download(ctx context.Context, _ *Task, _ string, _ chan<- Progress) (*Result, error) {
<-ctx.Done()
return nil, ctx.Err()
}
func (m *slowMockDownloader) Pause(_ string) error { return nil }
func (m *slowMockDownloader) Cancel(_ string) error { return nil }
func (m *slowMockDownloader) Shutdown(_ context.Context) error { return nil }

View file

@ -0,0 +1,50 @@
package engine
import "testing"
func TestDownloadMethodConstants(t *testing.T) {
if MethodTorrent != "torrent" {
t.Errorf("MethodTorrent = %q, want torrent", MethodTorrent)
}
if MethodDebrid != "debrid" {
t.Errorf("MethodDebrid = %q, want debrid", MethodDebrid)
}
if MethodUsenet != "usenet" {
t.Errorf("MethodUsenet = %q, want usenet", MethodUsenet)
}
}
func TestProgressStruct(t *testing.T) {
p := Progress{
DownloadedBytes: 1024,
TotalBytes: 2048,
SpeedBps: 512,
ETA: 10,
Peers: 5,
Seeds: 3,
FileName: "movie.mkv",
}
if p.DownloadedBytes != 1024 {
t.Errorf("DownloadedBytes = %d, want 1024", p.DownloadedBytes)
}
if p.FileName != "movie.mkv" {
t.Errorf("FileName = %q, want movie.mkv", p.FileName)
}
}
func TestResultStruct(t *testing.T) {
r := Result{
FilePath: "/downloads/movie.mkv",
FileName: "movie.mkv",
Method: MethodTorrent,
Size: 1073741824,
}
if r.Method != MethodTorrent {
t.Errorf("Method = %q, want torrent", r.Method)
}
if r.Size != 1073741824 {
t.Errorf("Size = %d, want 1073741824", r.Size)
}
}

View file

@ -3,6 +3,7 @@ package engine
import ( import (
"fmt" "fmt"
"io" "io"
"log"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@ -15,6 +16,17 @@ var (
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`) seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`) episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`)
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format
pathReplacer = strings.NewReplacer(
"/", "-",
"\\", "-",
":", " -",
"?", "",
"*", "",
"\"", "",
"<", "",
">", "",
"|", "-",
)
) )
// OrganizeConfig holds file organization settings. // OrganizeConfig holds file organization settings.
@ -22,36 +34,95 @@ type OrganizeConfig struct {
Enabled bool Enabled bool
MoviesDir string MoviesDir string
TVShowsDir string TVShowsDir string
OutputDir string // download directory — used to clean up torrent subdirectories after move
} }
// organize moves a downloaded file into the proper directory structure. // organize moves a downloaded file into the proper directory structure.
// Movies: MoviesDir/Title (Year)/filename.ext //
// TV: TVShowsDir/Title/Season XX/filename.ext // When server metadata is available (ContentType, ContentTitle, Season, CollectionName):
// - Shows: TVShowsDir/ContentTitle/Season XX/filename.ext
// - Collections: MoviesDir/CollectionName/ContentTitle (Year)/filename.ext
// - Movies: MoviesDir/ContentTitle (Year)/filename.ext
//
// Falls back to legacy regex-based detection when metadata is missing.
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) { func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
if !cfg.Enabled || result == nil || result.FilePath == "" { if !cfg.Enabled || result == nil || result.FilePath == "" {
return result.FilePath, nil return result.FilePath, nil
} }
var destDir string
var destFileName string // empty = keep original filename
ext := filepath.Ext(result.FileName)
if ext == "" {
ext = filepath.Ext(result.FilePath)
}
if task.ContentType == "show" && cfg.TVShowsDir != "" {
// TV show: use clean title from server, group all episodes under one folder
showName := task.ContentTitle
if showName == "" {
showName = cleanTitle(task.Title) // fallback
}
destDir = filepath.Join(cfg.TVShowsDir, sanitizePath(showName))
if task.Season != nil {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %02d", *task.Season))
// Rename: "ShowName - S01E03.mkv" so media players identify it
if task.Episode != nil {
destFileName = fmt.Sprintf("%s - S%02dE%02d%s", sanitizePath(showName), *task.Season, *task.Episode, ext)
}
} else if season := detectSeason(result.FileName); season != "" {
destDir = filepath.Join(destDir, fmt.Sprintf("Season %s", season))
}
} else if task.CollectionName != "" && cfg.MoviesDir != "" {
// Collection movie: CollectionName/MovieTitle (Year)/file
collDir := sanitizePath(task.CollectionName)
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := resolveYear(task)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, collDir, fmt.Sprintf("%s (%s)", sanitizePath(movieName), year))
destFileName = fmt.Sprintf("%s (%s)%s", sanitizePath(movieName), year, ext)
} else {
destDir = filepath.Join(cfg.MoviesDir, collDir, sanitizePath(movieName))
destFileName = fmt.Sprintf("%s%s", sanitizePath(movieName), ext)
}
} else if task.ContentType == "movie" && cfg.MoviesDir != "" {
// Regular movie with server metadata
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := resolveYear(task)
if year != "" {
destDir = filepath.Join(cfg.MoviesDir, fmt.Sprintf("%s (%s)", sanitizePath(movieName), year))
destFileName = fmt.Sprintf("%s (%s)%s", sanitizePath(movieName), year, ext)
} else {
destDir = filepath.Join(cfg.MoviesDir, sanitizePath(movieName))
destFileName = fmt.Sprintf("%s%s", sanitizePath(movieName), ext)
}
} else {
// No server metadata: fall back to legacy regex-based detection
return organizeLegacy(result, task, cfg)
}
return moveToDir(result, destDir, destFileName, cfg)
}
// organizeLegacy is the original regex-based organize logic for tasks without server metadata.
func organizeLegacy(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
title := task.Title title := task.Title
if title == "" { if title == "" {
title = result.FileName title = result.FileName
} }
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") || season := detectSeason(result.FileName)
seasonRegex.MatchString(result.FileName) isTV := season != ""
// Detect season for TV (S01E05 or 1x05 format)
var season string
if m := episodeRegex.FindStringSubmatch(result.FileName); len(m) > 2 {
season = m[1]
isTV = true
} else if m := altEpRegex.FindStringSubmatch(result.FileName); len(m) > 2 {
season = fmt.Sprintf("%02s", m[1])
isTV = true
} else if m := seasonRegex.FindStringSubmatch(result.FileName); len(m) > 1 {
season = m[1]
isTV = true
}
var destDir string var destDir string
if isTV && cfg.TVShowsDir != "" { if isTV && cfg.TVShowsDir != "" {
@ -69,34 +140,38 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
destDir = filepath.Join(cfg.MoviesDir, movieName) destDir = filepath.Join(cfg.MoviesDir, movieName)
} }
} else { } else {
return result.FilePath, nil // no organize dirs configured return result.FilePath, nil
} }
// Validate destination is within the expected base directory return moveToDir(result, destDir, "", cfg)
var baseDir string }
if isTV && cfg.TVShowsDir != "" {
baseDir = cfg.TVShowsDir // moveToDir handles the actual directory creation and file move, including path traversal check.
} else { // If destFileName is non-empty, the file is renamed to that name (instead of keeping the original).
baseDir = cfg.MoviesDir func moveToDir(result *Result, destDir, destFileName string, cfg OrganizeConfig) (string, error) {
} // Validate destination is within an expected base directory
if !isWithinDir(baseDir, destDir) { if !((cfg.TVShowsDir != "" && isWithinDir(cfg.TVShowsDir, destDir)) ||
return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir) (cfg.MoviesDir != "" && isWithinDir(cfg.MoviesDir, destDir)) ||
(cfg.OutputDir != "" && isWithinDir(cfg.OutputDir, destDir))) {
return "", fmt.Errorf("path traversal blocked: %q is not within any configured directory", destDir)
} }
if err := os.MkdirAll(destDir, 0o755); err != nil { if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", fmt.Errorf("create dir: %w", err) return "", fmt.Errorf("create dir: %w", err)
} }
destPath := filepath.Join(destDir, filepath.Base(result.FilePath)) fileName := filepath.Base(result.FilePath)
if destFileName != "" {
fileName = destFileName
}
destPath := filepath.Join(destDir, fileName)
// Check if source is a directory (multi-file torrent)
srcInfo, err := os.Stat(result.FilePath) srcInfo, err := os.Stat(result.FilePath)
if err != nil { if err != nil {
return "", fmt.Errorf("stat source: %w", err) return "", fmt.Errorf("stat source: %w", err)
} }
if srcInfo.IsDir() { if srcInfo.IsDir() {
// For directories: remove existing destination if present, then rename
if _, err := os.Stat(destPath); err == nil { if _, err := os.Stat(destPath); err == nil {
os.RemoveAll(destPath) os.RemoveAll(destPath)
} }
@ -106,7 +181,6 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
return destPath, nil return destPath, nil
} }
// Try rename first (same filesystem), fall back to copy+delete
if err := os.Rename(result.FilePath, destPath); err != nil { if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil { if err := copyFile(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move file: %w", err) return "", fmt.Errorf("move file: %w", err)
@ -114,9 +188,162 @@ func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
os.Remove(result.FilePath) os.Remove(result.FilePath)
} }
// Move subtitle files alongside the video
moveSubtitles(result.FilePath, destDir, destFileName)
// Clean up the source torrent directory if it's a subdirectory of OutputDir
// and now empty or only contains junk files (nfo, txt, url, etc.)
cleanupSourceDir(result.FilePath, cfg.OutputDir)
return destPath, nil return destPath, nil
} }
// cleanupSourceDir removes the parent directory of srcFile if:
// - it's a subdirectory of outputDir (any depth, e.g. outputDir/TorrentName/ or outputDir/category/TorrentName/)
// - it contains no video files or subdirectories after the move
//
// This cleans up leftover junk files (nfo, txt, url, jpg) from multi-file torrents.
func cleanupSourceDir(srcFile, outputDir string) {
if outputDir == "" {
return
}
srcDir := filepath.Dir(srcFile)
absOutput, err1 := filepath.Abs(outputDir)
absSrcDir, err2 := filepath.Abs(srcDir)
if err1 != nil || err2 != nil {
return
}
// Never delete outputDir itself
if absSrcDir == absOutput {
return
}
// Must be within outputDir
if !strings.HasPrefix(absSrcDir, absOutput+string(os.PathSeparator)) {
return
}
entries, err := os.ReadDir(absSrcDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() {
return // has subdirectories, don't touch
}
if isVideoFile(e.Name()) || isSubtitleFile(e.Name()) {
return // still has video/subtitle files, don't clean
}
}
// Only junk files remain — remove the entire directory
if err := os.RemoveAll(absSrcDir); err != nil {
log.Printf("[organize] cleanup warning: failed to remove %s: %v", absSrcDir, err)
}
}
// isVideoFile checks if a filename has a common video extension.
func isVideoFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".mkv", ".mp4", ".avi", ".wmv", ".mov", ".flv", ".webm", ".m4v", ".ts", ".m2ts":
return true
}
return false
}
// detectSeason extracts the season number from a filename using regex (for fallback).
func detectSeason(fileName string) string {
if m := episodeRegex.FindStringSubmatch(fileName); len(m) > 2 {
return m[1]
}
if m := altEpRegex.FindStringSubmatch(fileName); len(m) > 2 {
return fmt.Sprintf("%02s", m[1])
}
if m := seasonRegex.FindStringSubmatch(fileName); len(m) > 1 {
return m[1]
}
return ""
}
// sanitizePath removes characters that are invalid in file/directory names.
func sanitizePath(name string) string {
s := pathReplacer.Replace(name)
s = strings.TrimSpace(s)
s = strings.TrimRight(s, ".")
if s == "" {
return "Unknown"
}
return s
}
// moveSubtitles moves subtitle files from the source directory to destDir.
// If destFileName is set (video was renamed), subtitles are renamed to match.
// Matches subtitles by video base name (e.g., "Movie.srt", "Movie.en.srt").
func moveSubtitles(srcVideoPath, destDir, destFileName string) {
srcDir := filepath.Dir(srcVideoPath)
videoBase := strings.TrimSuffix(filepath.Base(srcVideoPath), filepath.Ext(srcVideoPath))
destVideoBase := ""
if destFileName != "" {
destVideoBase = strings.TrimSuffix(destFileName, filepath.Ext(destFileName))
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return
}
for _, e := range entries {
if e.IsDir() || !isSubtitleFile(e.Name()) {
continue
}
// Match: subtitle must start with the video base name
// e.g., "Movie.srt", "Movie.en.srt", "Movie.forced.eng.srt"
if !strings.HasPrefix(e.Name(), videoBase) {
continue
}
subSrc := filepath.Join(srcDir, e.Name())
subDest := e.Name()
// Rename subtitle to match new video name if video was renamed
// e.g., "Movie.en.srt" → "Oppenheimer (2023).en.srt"
if destVideoBase != "" {
suffix := strings.TrimPrefix(e.Name(), videoBase) // ".en.srt" or ".srt"
subDest = destVideoBase + suffix
}
destPath := filepath.Join(destDir, subDest)
if err := os.Rename(subSrc, destPath); err != nil {
if err := copyFile(subSrc, destPath); err != nil {
log.Printf("[organize] warning: failed to move subtitle %s: %v", e.Name(), err)
continue
}
os.Remove(subSrc)
}
}
}
// resolveYear returns the content year as a string.
// Prefers the server-provided ContentYear; falls back to regex extraction from the torrent title.
func resolveYear(task *Task) string {
if task.ContentYear != nil && *task.ContentYear > 0 {
return fmt.Sprintf("%d", *task.ContentYear)
}
return yearRegex.FindString(task.Title)
}
// isSubtitleFile checks if a filename has a common subtitle extension.
func isSubtitleFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
switch ext {
case ".srt", ".sub", ".ass", ".ssa", ".vtt", ".idx":
return true
}
return false
}
// cleanTitle extracts a clean title from a torrent title string. // cleanTitle extracts a clean title from a torrent title string.
func cleanTitle(title string) string { func cleanTitle(title string) string {
// Remove year and everything after common separators // Remove year and everything after common separators

View file

@ -0,0 +1,560 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestReplaceFile(t *testing.T) {
tmp := t.TempDir()
backupDir := filepath.Join(tmp, "backups")
// Create "old" file
oldPath := filepath.Join(tmp, "movie.mkv")
os.WriteFile(oldPath, []byte("old content"), 0o644)
// Create "new" file
newPath := filepath.Join(tmp, "movie-new.mkv")
os.WriteFile(newPath, []byte("new better content"), 0o644)
err := replaceFile(oldPath, newPath, backupDir)
if err != nil {
t.Fatalf("replaceFile: %v", err)
}
// Old path should now contain new content
data, err := os.ReadFile(oldPath)
if err != nil {
t.Fatalf("read old path: %v", err)
}
if string(data) != "new better content" {
t.Errorf("old path content = %q, want 'new better content'", string(data))
}
// Backup should exist
entries, _ := os.ReadDir(backupDir)
if len(entries) != 1 {
t.Errorf("expected 1 backup file, got %d", len(entries))
}
// New file should be gone
if _, err := os.Stat(newPath); !os.IsNotExist(err) {
t.Error("new file should have been moved/deleted")
}
}
func TestReplaceFileOldNotFound(t *testing.T) {
tmp := t.TempDir()
err := replaceFile(filepath.Join(tmp, "nonexistent.mkv"), filepath.Join(tmp, "new.mkv"), "")
if err == nil {
t.Error("expected error when old file doesn't exist")
}
}
func TestCopyFile(t *testing.T) {
tmp := t.TempDir()
src := filepath.Join(tmp, "source.txt")
dst := filepath.Join(tmp, "dest.txt")
content := []byte("hello world copy test")
os.WriteFile(src, content, 0o644)
err := copyFile(src, dst)
if err != nil {
t.Fatalf("copyFile: %v", err)
}
data, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("read dest: %v", err)
}
if string(data) != string(content) {
t.Errorf("dest content = %q, want %q", string(data), string(content))
}
}
func TestCopyFileSrcNotFound(t *testing.T) {
tmp := t.TempDir()
err := copyFile(filepath.Join(tmp, "nope.txt"), filepath.Join(tmp, "out.txt"))
if err == nil {
t.Error("expected error when source doesn't exist")
}
}
func TestOrganizeNoDirs(t *testing.T) {
r := &Result{FilePath: "/tmp/file.mkv", FileName: "file.mkv"}
task := &Task{Title: "Movie"}
path, err := organize(r, task, OrganizeConfig{Enabled: true})
if err != nil {
t.Fatal(err)
}
if path != "/tmp/file.mkv" {
t.Errorf("should return original path when no dirs configured, got %q", path)
}
}
func TestOrganizeNilResult(t *testing.T) {
task := &Task{Title: "Movie"}
path, err := organize(&Result{}, task, OrganizeConfig{Enabled: true})
if err != nil {
t.Fatal(err)
}
if path != "" {
t.Errorf("expected empty path for empty result, got %q", path)
}
}
func TestOrganizeMovieDirectory(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "src", "MovieDir")
os.MkdirAll(srcDir, 0o755)
os.WriteFile(filepath.Join(srcDir, "movie.mkv"), []byte("data"), 0o644)
moviesDir := filepath.Join(tmp, "Movies")
r := &Result{FilePath: srcDir, FileName: "MovieDir"}
task := &Task{Title: "My Movie 2023"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
})
if err != nil {
t.Fatal(err)
}
if path == srcDir {
t.Error("directory should have moved")
}
if _, err := os.Stat(path); err != nil {
t.Errorf("organized directory should exist at %s", path)
}
}
func TestOrganizeSeasonOnly(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Show.S01.Complete.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
tvDir := filepath.Join(tmp, "TV")
r := &Result{FilePath: srcFile, FileName: "Show.S01.Complete.mkv"}
task := &Task{Title: "Show S01"}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
})
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(path)
if filepath.Base(dir) != "Season 01" {
t.Errorf("expected Season 01 directory, got %q", filepath.Base(dir))
}
}
// --- Tests for server metadata organize path ---
func intPtr(v int) *int { return &v }
func TestOrganizeShowWithMetadata(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Frieren.Beyond.Journeys.End.S01E03.1080p.WEB-DL.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
tvDir := filepath.Join(tmp, "TV Shows")
r := &Result{FilePath: srcFile, FileName: "Frieren.Beyond.Journeys.End.S01E03.1080p.WEB-DL.mkv"}
task := &Task{
Title: "Frieren.Beyond.Journeys.End.S01E03.1080p.WEB-DL",
ContentType: "show",
ContentTitle: "Frieren: Beyond Journey's End",
Season: intPtr(1),
Episode: intPtr(3),
}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
})
if err != nil {
t.Fatal(err)
}
// Should be: TV Shows/Frieren - Beyond Journey's End/Season 01/Frieren - Beyond Journey's End - S01E03.mkv
dir := filepath.Dir(path)
if filepath.Base(dir) != "Season 01" {
t.Errorf("expected Season 01 directory, got %q", filepath.Base(dir))
}
showDir := filepath.Dir(dir)
if filepath.Base(showDir) != "Frieren - Beyond Journey's End" {
t.Errorf("expected show dir 'Frieren - Beyond Journey's End', got %q", filepath.Base(showDir))
}
// Filename should be clean
base := filepath.Base(path)
if base != "Frieren - Beyond Journey's End - S01E03.mkv" {
t.Errorf("filename = %q, want 'Frieren - Beyond Journey's End - S01E03.mkv'", base)
}
}
func TestOrganizeCollectionMovieWithMetadata(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Knives.Out.2019.1080p.BluRay.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
moviesDir := filepath.Join(tmp, "Movies")
r := &Result{FilePath: srcFile, FileName: "Knives.Out.2019.1080p.BluRay.mkv"}
task := &Task{
Title: "Knives.Out.2019.1080p.BluRay",
ContentType: "movie",
ContentTitle: "Knives Out",
CollectionName: "Knives Out Collection",
}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
})
if err != nil {
t.Fatal(err)
}
// Should be: Movies/Knives Out Collection/Knives Out (2019)/Knives Out (2019).mkv
movieDir := filepath.Dir(path)
if filepath.Base(movieDir) != "Knives Out (2019)" {
t.Errorf("expected movie dir 'Knives Out (2019)', got %q", filepath.Base(movieDir))
}
collDir := filepath.Dir(movieDir)
if filepath.Base(collDir) != "Knives Out Collection" {
t.Errorf("expected collection dir 'Knives Out Collection', got %q", filepath.Base(collDir))
}
base := filepath.Base(path)
if base != "Knives Out (2019).mkv" {
t.Errorf("filename = %q, want 'Knives Out (2019).mkv'", base)
}
}
func TestOrganizeMovieWithMetadata(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Oppenheimer.2023.2160p.UHD.BluRay.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
moviesDir := filepath.Join(tmp, "Movies")
r := &Result{FilePath: srcFile, FileName: "Oppenheimer.2023.2160p.UHD.BluRay.mkv"}
task := &Task{
Title: "Oppenheimer.2023.2160p.UHD.BluRay",
ContentType: "movie",
ContentTitle: "Oppenheimer",
}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
})
if err != nil {
t.Fatal(err)
}
// Should be: Movies/Oppenheimer (2023)/Oppenheimer (2023).mkv
movieDir := filepath.Dir(path)
if filepath.Base(movieDir) != "Oppenheimer (2023)" {
t.Errorf("expected movie dir 'Oppenheimer (2023)', got %q", filepath.Base(movieDir))
}
base := filepath.Base(path)
if base != "Oppenheimer (2023).mkv" {
t.Errorf("filename = %q, want 'Oppenheimer (2023).mkv'", base)
}
}
func TestOrganizeMultipleEpisodesSameFolder(t *testing.T) {
tmp := t.TempDir()
tvDir := filepath.Join(tmp, "TV Shows")
// Simulate two episodes of the same show
for _, ep := range []int{1, 2} {
srcFile := filepath.Join(tmp, filepath.Base(t.TempDir())+".mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
r := &Result{FilePath: srcFile, FileName: filepath.Base(srcFile)}
task := &Task{
Title: "Frieren.S01E0" + string(rune('0'+ep)) + ".1080p",
ContentType: "show",
ContentTitle: "Frieren",
Season: intPtr(1),
Episode: intPtr(ep),
}
_, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
})
if err != nil {
t.Fatalf("episode %d: %v", ep, err)
}
}
// Both episodes should be in the same directory
seasonDir := filepath.Join(tvDir, "Frieren", "Season 01")
entries, err := os.ReadDir(seasonDir)
if err != nil {
t.Fatalf("read season dir: %v", err)
}
if len(entries) != 2 {
t.Errorf("expected 2 files in Season 01, got %d", len(entries))
}
}
func TestOrganizeCleanupSourceDir(t *testing.T) {
tmp := t.TempDir()
// Simulate: outputDir/TorrentName/video.mkv + junk files
outputDir := filepath.Join(tmp, "downloads")
torrentDir := filepath.Join(outputDir, "Frieren.S01E03.1080p.WEB-DL")
os.MkdirAll(torrentDir, 0o755)
srcFile := filepath.Join(torrentDir, "Frieren.S01E03.1080p.WEB-DL.mkv")
os.WriteFile(srcFile, []byte("video"), 0o644)
os.WriteFile(filepath.Join(torrentDir, "info.nfo"), []byte("nfo"), 0o644)
os.WriteFile(filepath.Join(torrentDir, "readme.txt"), []byte("txt"), 0o644)
os.WriteFile(filepath.Join(torrentDir, "website.url"), []byte("url"), 0o644)
tvDir := filepath.Join(tmp, "TV Shows")
r := &Result{FilePath: srcFile, FileName: "Frieren.S01E03.1080p.WEB-DL.mkv"}
task := &Task{
Title: "Frieren.S01E03.1080p.WEB-DL",
ContentType: "show",
ContentTitle: "Frieren",
Season: intPtr(1),
Episode: intPtr(3),
}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
OutputDir: outputDir,
})
if err != nil {
t.Fatal(err)
}
// Video should be in organized location
if _, err := os.Stat(path); err != nil {
t.Errorf("organized file should exist at %s", path)
}
// Source torrent directory should be gone (only had junk left)
if _, err := os.Stat(torrentDir); !os.IsNotExist(err) {
t.Errorf("torrent dir should have been cleaned up: %s", torrentDir)
}
// OutputDir itself should still exist
if _, err := os.Stat(outputDir); err != nil {
t.Errorf("outputDir should still exist")
}
}
func TestOrganizeNoCleanupWhenVideoRemains(t *testing.T) {
tmp := t.TempDir()
outputDir := filepath.Join(tmp, "downloads")
torrentDir := filepath.Join(outputDir, "MultiVideoTorrent")
os.MkdirAll(torrentDir, 0o755)
srcFile := filepath.Join(torrentDir, "episode1.mkv")
os.WriteFile(srcFile, []byte("video1"), 0o644)
// Another video file remains
os.WriteFile(filepath.Join(torrentDir, "episode2.mkv"), []byte("video2"), 0o644)
tvDir := filepath.Join(tmp, "TV Shows")
r := &Result{FilePath: srcFile, FileName: "episode1.mkv"}
task := &Task{
Title: "Show S01E01",
ContentType: "show",
ContentTitle: "Show",
Season: intPtr(1),
Episode: intPtr(1),
}
_, err := organize(r, task, OrganizeConfig{
Enabled: true,
TVShowsDir: tvDir,
OutputDir: outputDir,
})
if err != nil {
t.Fatal(err)
}
// Torrent dir should still exist because episode2.mkv is still there
if _, err := os.Stat(torrentDir); err != nil {
t.Errorf("torrent dir should NOT be cleaned up when video files remain")
}
}
func TestSanitizePath(t *testing.T) {
tests := []struct {
input string
want string
}{
{"Normal Title", "Normal Title"},
{"Title: Subtitle", "Title - Subtitle"},
{"Title/Subtitle", "Title-Subtitle"},
{"What?", "What"},
{"A*B<C>D|E", "ABCD-E"},
{" Spaces ", "Spaces"},
{"Trailing...", "Trailing"},
{"", "Unknown"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := sanitizePath(tt.input)
if got != tt.want {
t.Errorf("sanitizePath(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}
func TestResolveYear(t *testing.T) {
tests := []struct {
name string
task *Task
want string
}{
{"from ContentYear", &Task{ContentYear: intPtr(2023), Title: "Movie.2020.1080p"}, "2023"},
{"fallback to regex", &Task{Title: "Movie.2020.1080p"}, "2020"},
{"no year", &Task{Title: "Movie.1080p"}, ""},
{"zero year fallback", &Task{ContentYear: intPtr(0), Title: "Movie.2019.mkv"}, "2019"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveYear(tt.task)
if got != tt.want {
t.Errorf("resolveYear() = %q, want %q", got, tt.want)
}
})
}
}
func TestIsSubtitleFile(t *testing.T) {
for _, ext := range []string{".srt", ".sub", ".ass", ".ssa", ".vtt", ".idx"} {
if !isSubtitleFile("file" + ext) {
t.Errorf("expected %s to be subtitle", ext)
}
}
for _, ext := range []string{".mkv", ".txt", ".nfo", ".jpg"} {
if isSubtitleFile("file" + ext) {
t.Errorf("expected %s to NOT be subtitle", ext)
}
}
}
func TestMoveSubtitles(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "torrent")
destDir := filepath.Join(tmp, "dest")
os.MkdirAll(srcDir, 0o755)
os.MkdirAll(destDir, 0o755)
// Create video + subtitles in source
videoPath := filepath.Join(srcDir, "Movie.2023.1080p.mkv")
os.WriteFile(videoPath, []byte("video"), 0o644)
os.WriteFile(filepath.Join(srcDir, "Movie.2023.1080p.srt"), []byte("srt"), 0o644)
os.WriteFile(filepath.Join(srcDir, "Movie.2023.1080p.en.srt"), []byte("en srt"), 0o644)
os.WriteFile(filepath.Join(srcDir, "Other.srt"), []byte("other"), 0o644) // should NOT move
moveSubtitles(videoPath, destDir, "Oppenheimer (2023).mkv")
// Renamed subtitles should be in dest
if _, err := os.Stat(filepath.Join(destDir, "Oppenheimer (2023).srt")); err != nil {
t.Error("expected Oppenheimer (2023).srt in dest")
}
if _, err := os.Stat(filepath.Join(destDir, "Oppenheimer (2023).en.srt")); err != nil {
t.Error("expected Oppenheimer (2023).en.srt in dest")
}
// Other.srt should NOT have moved
if _, err := os.Stat(filepath.Join(srcDir, "Other.srt")); err != nil {
t.Error("Other.srt should remain in source")
}
}
func TestMoveSubtitlesNoRename(t *testing.T) {
tmp := t.TempDir()
srcDir := filepath.Join(tmp, "torrent")
destDir := filepath.Join(tmp, "dest")
os.MkdirAll(srcDir, 0o755)
os.MkdirAll(destDir, 0o755)
videoPath := filepath.Join(srcDir, "Movie.mkv")
os.WriteFile(videoPath, []byte("video"), 0o644)
os.WriteFile(filepath.Join(srcDir, "Movie.srt"), []byte("srt"), 0o644)
moveSubtitles(videoPath, destDir, "") // no rename
if _, err := os.Stat(filepath.Join(destDir, "Movie.srt")); err != nil {
t.Error("expected Movie.srt in dest (no rename)")
}
}
func TestOrganizeMovieWithContentYear(t *testing.T) {
tmp := t.TempDir()
srcFile := filepath.Join(tmp, "Oppenheimer.UHD.BluRay.mkv")
os.WriteFile(srcFile, []byte("data"), 0o644)
moviesDir := filepath.Join(tmp, "Movies")
r := &Result{FilePath: srcFile, FileName: "Oppenheimer.UHD.BluRay.mkv"}
task := &Task{
Title: "Oppenheimer.UHD.BluRay", // no year in title!
ContentType: "movie",
ContentTitle: "Oppenheimer",
ContentYear: intPtr(2023),
}
path, err := organize(r, task, OrganizeConfig{
Enabled: true,
MoviesDir: moviesDir,
})
if err != nil {
t.Fatal(err)
}
// Should use ContentYear even though title has no year
movieDir := filepath.Dir(path)
if filepath.Base(movieDir) != "Oppenheimer (2023)" {
t.Errorf("expected movie dir 'Oppenheimer (2023)', got %q", filepath.Base(movieDir))
}
base := filepath.Base(path)
if base != "Oppenheimer (2023).mkv" {
t.Errorf("filename = %q, want 'Oppenheimer (2023).mkv'", base)
}
}
func TestCleanTitleEdgeCases(t *testing.T) {
tests := []struct {
input string
want string
}{
{"", ""},
{"Simple Title", "Simple Title"},
{"Title (2023) 1080p BluRay", "Title"},
{"Title 720p HDTV", "Title"},
{"Title x264 HEVC", "Title"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := cleanTitle(tt.input)
if got != tt.want {
t.Errorf("cleanTitle(%q) = %q, want %q", tt.input, got, tt.want)
}
})
}
}

186
internal/engine/probe.go Normal file
View file

@ -0,0 +1,186 @@
package engine
import (
"context"
"fmt"
"strings"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// StreamProbe summarises the codec / container shape of a file as it relates
// 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".
VideoCodec string
// AudioCodec lowercased — e.g. "aac", "ac3", "dts", "eac3", "opus".
// Reflects the default/first audio track for legacy single-track callers.
AudioCodec string
// Width / Height of the primary video stream.
Width int
Height int
// BitDepth — 8, 10 or 12. 0 if unknown.
BitDepth int
// HDR signalling string ("HDR10" / "DV" / "HLG" / etc, or "" for SDR).
HDR string
// DurationSec is the file length, used to sanity-check seek targets.
DurationSec float64
// Container is the file extension lowercased (".mp4", ".mkv", ".avi").
Container string
// AudioTracks lists every audio stream in source order. Index in this
// slice == ffmpeg `-map 0:a:N` index (where N starts at 0).
AudioTracks []ProbeAudioTrack
// SubtitleTracks lists every subtitle stream in source order. Index in
// this slice == ffmpeg `-map 0:s:N` index.
SubtitleTracks []ProbeSubtitleTrack
}
// ProbeAudioTrack is a slimmed AudioTrack view tied to ffmpeg stream index.
type ProbeAudioTrack struct {
Index int // 0-based audio stream index (ffmpeg -map 0:a:Index)
Lang string // ISO 639-1
Codec string // lowercased
Channels int
Title string
Default bool
}
// ProbeSubtitleTrack is a slimmed SubtitleTrack view tied to ffmpeg stream index.
// Codec discriminates text (srt/ass/webvtt → extract to WebVTT) vs bitmap
// (pgs/dvbsub → require burn-in).
type ProbeSubtitleTrack struct {
Index int // 0-based subtitle stream index (ffmpeg -map 0:s:Index)
Lang string // ISO 639-1
Codec string // lowercased — "subrip", "ass", "webvtt", "hdmv_pgs_subtitle", ...
Title string
Forced bool
}
// IsTextSubtitle reports whether a subtitle codec can be extracted to WebVTT
// without re-rendering. Bitmap subs (PGS, DVB) need burn-in.
func (s ProbeSubtitleTrack) IsTextSubtitle() bool {
switch s.Codec {
case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text":
return true
}
return false
}
// TranscodeAction tells the streaming pipeline how to feed the file to
// the browser <video> element. The decision matrix is documented in the
// project plan (Fase 2.5 — Transcoding on-the-fly).
type TranscodeAction string
const (
// ActionPassthrough — file is already browser-playable as-is. Stream the
// raw bytes via ReadAt; no ffmpeg involved.
ActionPassthrough TranscodeAction = "passthrough"
// ActionRemux — codecs are browser-compatible but the container or moov
// placement is not. Run ffmpeg with `-c copy -movflags frag_keyframe`.
ActionRemux TranscodeAction = "remux"
// ActionRemuxAudio — video is fine but audio needs a re-encode (AC3/DTS
// → AAC). `-c:v copy -c:a aac`.
ActionRemuxAudio TranscodeAction = "remux-audio"
// ActionTranscodeVideo — full re-encode. Used for HEVC/AV1 and any
// 10-bit content if the browser refuses the codec.
ActionTranscodeVideo TranscodeAction = "transcode-video"
)
// 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)
}
probe := &StreamProbe{Container: lowerExt(filePath)}
if mi.Video != nil {
probe.VideoCodec = strings.ToLower(mi.Video.Codec)
probe.Width = mi.Video.Width
probe.Height = mi.Video.Height
probe.BitDepth = mi.Video.BitDepth
probe.HDR = mi.Video.HDR
probe.DurationSec = mi.Video.Duration
}
if len(mi.Audio) > 0 {
// Default to the first track marked "Default", else the first track.
picked := mi.Audio[0]
for _, a := range mi.Audio {
if a.Default {
picked = a
break
}
}
probe.AudioCodec = strings.ToLower(picked.Codec)
probe.AudioTracks = make([]ProbeAudioTrack, 0, len(mi.Audio))
for i, a := range mi.Audio {
probe.AudioTracks = append(probe.AudioTracks, ProbeAudioTrack{
Index: i,
Lang: a.Lang,
Codec: strings.ToLower(a.Codec),
Channels: a.Channels,
Title: a.Title,
Default: a.Default,
})
}
}
if len(mi.Subtitles) > 0 {
probe.SubtitleTracks = make([]ProbeSubtitleTrack, 0, len(mi.Subtitles))
for i, s := range mi.Subtitles {
probe.SubtitleTracks = append(probe.SubtitleTracks, ProbeSubtitleTrack{
Index: i,
Lang: s.Lang,
Codec: strings.ToLower(s.Codec),
Title: s.Title,
Forced: s.Forced,
})
}
}
storeProbeCache(filePath, probe)
return probe, nil
}
// DecideAction maps a probe to the transcoding action the streaming pipeline
// should take. Browsers consume MP4/h264+AAC natively; everything else needs
// some level of re-shaping.
func DecideAction(p *StreamProbe) TranscodeAction {
if p == nil {
return ActionPassthrough
}
video := p.VideoCodec
audio := p.AudioCodec
container := p.Container
// 10-bit / HDR is a hard no for browser playback even if h264 — needs SW transcode.
tenBitOrHDR := p.BitDepth >= 10 || p.HDR != ""
if !tenBitOrHDR && video == "h264" {
if audio == "aac" {
if container == ".mp4" {
return ActionPassthrough
}
return ActionRemux
}
// Audio incompatible (AC3/DTS/TrueHD/EAC3) → remux video, transcode audio.
return ActionRemuxAudio
}
// HEVC / AV1 / VP9 / 10-bit / unknown → full re-encode video.
return ActionTranscodeVideo
}
func lowerExt(filePath string) string {
dot := strings.LastIndex(filePath, ".")
if dot < 0 {
return ""
}
return strings.ToLower(filePath[dot:])
}

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

@ -0,0 +1,96 @@
package engine
import "testing"
func TestDecideAction(t *testing.T) {
cases := []struct {
name string
p StreamProbe
want TranscodeAction
}{
{
name: "MP4 + h264 + AAC = passthrough",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", Container: ".mp4"},
want: ActionPassthrough,
},
{
name: "MKV + h264 + AAC = remux",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", Container: ".mkv"},
want: ActionRemux,
},
{
name: "MKV + h264 + AC3 = remux audio",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "ac3", Container: ".mkv"},
want: ActionRemuxAudio,
},
{
name: "MP4 + h264 + EAC3 = remux audio",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "eac3", Container: ".mp4"},
want: ActionRemuxAudio,
},
{
name: "MKV + HEVC = transcode video",
p: StreamProbe{VideoCodec: "hevc", AudioCodec: "aac", Container: ".mkv"},
want: ActionTranscodeVideo,
},
{
name: "MP4 + AV1 = transcode video",
p: StreamProbe{VideoCodec: "av1", AudioCodec: "aac", Container: ".mp4"},
want: ActionTranscodeVideo,
},
{
name: "h264 10-bit = transcode video (browser refuses)",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", BitDepth: 10, Container: ".mp4"},
want: ActionTranscodeVideo,
},
{
name: "h264 + HDR10 = transcode video",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", HDR: "HDR10", Container: ".mp4"},
want: ActionTranscodeVideo,
},
{
name: "AVI + h264 + AAC = remux",
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", Container: ".avi"},
want: ActionRemux,
},
{
name: "Unknown codec = transcode video",
p: StreamProbe{VideoCodec: "mpeg4", AudioCodec: "mp3", Container: ".avi"},
want: ActionTranscodeVideo,
},
{
name: "Empty probe falls through to transcode (unknown codec)",
p: StreamProbe{},
want: ActionTranscodeVideo,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := DecideAction(&tc.p)
if got != tc.want {
t.Errorf("got %s, want %s", got, tc.want)
}
})
}
}
func TestDecideActionNil(t *testing.T) {
if DecideAction(nil) != ActionPassthrough {
t.Error("nil probe should default passthrough")
}
}
func TestLowerExt(t *testing.T) {
cases := map[string]string{
"foo.MP4": ".mp4",
"path/to/movie.MKV": ".mkv",
"weird.name.with.dots": ".dots",
"": "",
"noext": "",
}
for in, want := range cases {
if got := lowerExt(in); got != want {
t.Errorf("lowerExt(%q) = %q want %q", in, got, want)
}
}
}

View file

@ -13,13 +13,11 @@ import (
type ActionFunc func(taskID string) type ActionFunc func(taskID string)
// StatusReporter is the interface used by ProgressReporter to send progress updates. // StatusReporter is the interface used by ProgressReporter to send progress updates.
// Both *agent.Client and agent.Transport implement this via their ReportStatus/SendProgress methods.
type StatusReporter interface { type StatusReporter interface {
ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error) ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error)
} }
// BatchStatusReporter extends StatusReporter with batch support. // BatchStatusReporter extends StatusReporter with batch support.
// Transports that implement this send all updates in a single request.
type BatchStatusReporter interface { type BatchStatusReporter interface {
StatusReporter StatusReporter
BatchReportStatus(ctx context.Context, updates []agent.StatusUpdate) (*agent.BatchStatusResponse, error) BatchReportStatus(ctx context.Context, updates []agent.StatusUpdate) (*agent.BatchStatusResponse, error)
@ -39,39 +37,24 @@ type ProgressReporter struct {
onPause ActionFunc onPause ActionFunc
onDeleteFiles ActionFunc onDeleteFiles ActionFunc
onStreamRequested ActionFunc onStreamRequested ActionFunc
onWatchingChanged func(watching bool)
mu sync.Mutex mu sync.Mutex
latest map[string]*Task // taskID -> task with latest progress latest map[string]*Task // taskID -> task with latest progress
lastReported map[string]TaskStatus // taskID -> last status sent to API
lastCheckAt time.Time // last time we reported for control-signal polling
} }
// NewProgressReporter creates a reporter that flushes every interval. // NewProgressReporter creates a reporter that flushes every interval.
// Accepts *agent.Client directly (backwards compatible).
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter { func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
return &ProgressReporter{ return &ProgressReporter{
reporter: ac, reporter: ac,
interval: interval, interval: interval,
latest: make(map[string]*Task), latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
} }
} }
// NewProgressReporterWithTransport creates a reporter using a Transport.
func NewProgressReporterWithTransport(t agent.Transport, interval time.Duration) *ProgressReporter {
return &ProgressReporter{
reporter: &transportStatusAdapter{t: t},
interval: interval,
latest: make(map[string]*Task),
}
}
// transportStatusAdapter adapts agent.Transport to StatusReporter.
type transportStatusAdapter struct {
t agent.Transport
}
func (a *transportStatusAdapter) ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error) {
return a.t.SendProgress(ctx, update)
}
// SetCancelHandler sets the callback invoked when the server says a task is cancelled. // SetCancelHandler sets the callback invoked when the server says a task is cancelled.
func (r *ProgressReporter) SetCancelHandler(fn ActionFunc) { r.onCancel = fn } func (r *ProgressReporter) SetCancelHandler(fn ActionFunc) { r.onCancel = fn }
@ -87,6 +70,12 @@ func (r *ProgressReporter) SetStreamRequestedHandler(fn ActionFunc) { r.onStream
// SetWatchingFunc sets the function that checks if someone is viewing downloads. // SetWatchingFunc sets the function that checks if someone is viewing downloads.
func (r *ProgressReporter) SetWatchingFunc(fn WatchingFunc) { r.isWatching = fn } func (r *ProgressReporter) SetWatchingFunc(fn WatchingFunc) { r.isWatching = fn }
// SetWatchingChangedHandler sets a callback invoked when the server's watching flag changes.
// This allows the daemon to update its Watching state from status responses (not just heartbeats).
func (r *ProgressReporter) SetWatchingChangedHandler(fn func(watching bool)) {
r.onWatchingChanged = fn
}
// Track registers a task for progress tracking. // Track registers a task for progress tracking.
func (r *ProgressReporter) Track(task *Task) { func (r *ProgressReporter) Track(task *Task) {
r.mu.Lock() r.mu.Lock()
@ -99,6 +88,7 @@ func (r *ProgressReporter) Untrack(taskID string) {
r.mu.Lock() r.mu.Lock()
defer r.mu.Unlock() defer r.mu.Unlock()
delete(r.latest, taskID) delete(r.latest, taskID)
delete(r.lastReported, taskID)
} }
// Run starts the periodic flush loop. Blocks until ctx is cancelled. // Run starts the periodic flush loop. Blocks until ctx is cancelled.
@ -123,23 +113,38 @@ func (r *ProgressReporter) flush(ctx context.Context) {
for _, t := range r.latest { for _, t := range r.latest {
tasks = append(tasks, t) tasks = append(tasks, t)
} }
// Snapshot lastReported under the same lock
lastReported := make(map[string]TaskStatus, len(r.lastReported))
for k, v := range r.lastReported {
lastReported[k] = v
}
r.mu.Unlock() r.mu.Unlock()
// When nobody is watching, only report final states (completed/failed). // When nobody is watching, only report final states, status transitions,
// This saves ~99% of API requests when the user isn't on the downloads page. // and periodic check-ins (every 30s) so we still receive control signals
// (cancel/pause) from the server.
watching := r.isWatching == nil || r.isWatching() watching := r.isWatching == nil || r.isWatching()
controlCheckDue := time.Since(r.lastCheckAt) >= 30*time.Second
var reportable []*Task var reportable []*Task
for _, task := range tasks { for _, task := range tasks {
status := task.GetStatus() status := task.GetStatus()
isFinal := status == StatusCompleted || status == StatusFailed isFinal := status == StatusCompleted || status == StatusFailed
isActive := status == StatusDownloading || status == StatusVerifying || isActive := status == StatusDownloading || status == StatusVerifying ||
status == StatusOrganizing || status == StatusSeeding status == StatusOrganizing || status == StatusSeeding ||
if isFinal || (watching && isActive) { status == StatusResolving
// Always report status transitions so the DB reflects the current state.
prev := lastReported[task.ID]
isTransition := prev == "" || prev != status
if isFinal || isTransition || (watching && isActive) || (controlCheckDue && isActive) {
reportable = append(reportable, task) reportable = append(reportable, task)
} }
} }
if controlCheckDue {
r.lastCheckAt = time.Now()
}
if len(reportable) == 0 { if len(reportable) == 0 {
return return
} }
@ -152,20 +157,27 @@ func (r *ProgressReporter) flush(ctx context.Context) {
// Fallback: individual requests // Fallback: individual requests
for _, task := range reportable { for _, task := range reportable {
statusAtReport := task.GetStatus() // capture before HTTP round-trip
update := task.ToStatusUpdate() update := task.ToStatusUpdate()
resp, err := r.reporter.ReportStatus(ctx, update) resp, err := r.reporter.ReportStatus(ctx, update)
if err != nil { if err != nil {
log.Printf("[%s] progress report failed: %v", task.ID[:8], err) log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
continue continue
} }
r.mu.Lock()
r.lastReported[task.ID] = statusAtReport
r.mu.Unlock()
r.handleResponse(task, resp) r.handleResponse(task, resp)
} }
} }
func (r *ProgressReporter) flushBatch(ctx context.Context, batcher BatchStatusReporter, tasks []*Task) { func (r *ProgressReporter) flushBatch(ctx context.Context, batcher BatchStatusReporter, tasks []*Task) {
updates := make([]agent.StatusUpdate, len(tasks)) updates := make([]agent.StatusUpdate, len(tasks))
// Capture status before HTTP round-trip to avoid missed transitions
statusAtReport := make([]TaskStatus, len(tasks))
for i, task := range tasks { for i, task := range tasks {
updates[i] = task.ToStatusUpdate() updates[i] = task.ToStatusUpdate()
statusAtReport[i] = task.GetStatus()
} }
resp, err := batcher.BatchReportStatus(ctx, updates) resp, err := batcher.BatchReportStatus(ctx, updates)
@ -174,10 +186,20 @@ func (r *ProgressReporter) flushBatch(ctx context.Context, batcher BatchStatusRe
return return
} }
// Propagate watching flag from batch response
if resp.Watching && r.onWatchingChanged != nil {
r.onWatchingChanged(true)
}
// Match results back to tasks by index (server returns in same order) // Match results back to tasks by index (server returns in same order)
if len(resp.Results) != len(tasks) { if len(resp.Results) != len(tasks) {
log.Printf("batch response mismatch: sent %d updates, got %d results", len(tasks), len(resp.Results)) log.Printf("batch response mismatch: sent %d updates, got %d results", len(tasks), len(resp.Results))
} }
r.mu.Lock()
for i, task := range tasks {
r.lastReported[task.ID] = statusAtReport[i]
}
r.mu.Unlock()
for i, result := range resp.Results { for i, result := range resp.Results {
if i < len(tasks) { if i < len(tasks) {
r.handleResponse(tasks[i], &result) r.handleResponse(tasks[i], &result)
@ -186,6 +208,11 @@ func (r *ProgressReporter) flushBatch(ctx context.Context, batcher BatchStatusRe
} }
func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse) { func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse) {
// Propagate watching flag from status response to daemon
if resp.Watching && r.onWatchingChanged != nil {
r.onWatchingChanged(true)
}
if resp.Cancelled { if resp.Cancelled {
log.Printf("[%s] cancelled by user (via web)", task.ID[:8]) log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
r.Untrack(task.ID) r.Untrack(task.ID)

View file

@ -0,0 +1,419 @@
package engine
import (
"context"
"sync"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// mockStatusReporter records calls to ReportStatus.
type mockStatusReporter struct {
mu sync.Mutex
calls []agent.StatusUpdate
resp *agent.StatusResponse
respErr error
}
func (m *mockStatusReporter) ReportStatus(_ context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.calls = append(m.calls, update)
if m.resp != nil {
return m.resp, m.respErr
}
return &agent.StatusResponse{}, m.respErr
}
// mockBatchReporter records batch calls.
type mockBatchReporter struct {
mockStatusReporter
batchCalls [][]agent.StatusUpdate
batchResp *agent.BatchStatusResponse
}
func (m *mockBatchReporter) BatchReportStatus(_ context.Context, updates []agent.StatusUpdate) (*agent.BatchStatusResponse, error) {
m.mu.Lock()
defer m.mu.Unlock()
m.batchCalls = append(m.batchCalls, updates)
if m.batchResp != nil {
return m.batchResp, nil
}
results := make([]agent.StatusResponse, len(updates))
return &agent.BatchStatusResponse{Results: results}, nil
}
func TestProgressReporter_TrackUntrack(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
task := &Task{ID: "task-001", Status: StatusDownloading}
pr.Track(task)
pr.mu.Lock()
if _, ok := pr.latest["task-001"]; !ok {
t.Error("task should be tracked")
}
pr.mu.Unlock()
pr.Untrack("task-001")
pr.mu.Lock()
if _, ok := pr.latest["task-001"]; ok {
t.Error("task should be untracked")
}
pr.mu.Unlock()
}
func TestProgressReporter_FlushReportsFinalStates(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
completed := &Task{ID: "task-completed-1234", Status: StatusCompleted}
pr.Track(completed)
pr.flush(context.Background())
reporter.mu.Lock()
defer reporter.mu.Unlock()
if len(reporter.calls) != 1 {
t.Fatalf("expected 1 report, got %d", len(reporter.calls))
}
if reporter.calls[0].TaskID != "task-completed-1234" {
t.Errorf("reported wrong task: %s", reporter.calls[0].TaskID)
}
}
func TestProgressReporter_FlushSkipsWhenNotWatching(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
isWatching: func() bool { return false },
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
lastCheckAt: time.Now(), // not due for control check
}
// Active downloading task, already reported as downloading
task := &Task{ID: "task-active-12345678", Status: StatusDownloading}
pr.Track(task)
pr.mu.Lock()
pr.lastReported["task-active-12345678"] = StatusDownloading
pr.mu.Unlock()
pr.flush(context.Background())
reporter.mu.Lock()
defer reporter.mu.Unlock()
if len(reporter.calls) != 0 {
t.Errorf("expected 0 reports when not watching (no transition), got %d", len(reporter.calls))
}
}
func TestProgressReporter_FlushReportsTransitions(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
isWatching: func() bool { return false },
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
lastCheckAt: time.Now(),
}
// Task transitioning from resolving to downloading
task := &Task{ID: "task-trans-12345678", Status: StatusDownloading}
pr.Track(task)
pr.mu.Lock()
pr.lastReported["task-trans-12345678"] = StatusResolving
pr.mu.Unlock()
pr.flush(context.Background())
reporter.mu.Lock()
defer reporter.mu.Unlock()
if len(reporter.calls) != 1 {
t.Fatalf("expected 1 report for transition, got %d", len(reporter.calls))
}
}
func TestProgressReporter_FlushActiveWhenWatching(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
isWatching: func() bool { return true },
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
task := &Task{ID: "task-watch-12345678", Status: StatusDownloading}
pr.Track(task)
pr.mu.Lock()
pr.lastReported["task-watch-12345678"] = StatusDownloading
pr.mu.Unlock()
pr.flush(context.Background())
reporter.mu.Lock()
defer reporter.mu.Unlock()
if len(reporter.calls) != 1 {
t.Fatalf("expected 1 report when watching active task, got %d", len(reporter.calls))
}
}
func TestProgressReporter_HandleResponseCancel(t *testing.T) {
reporter := &mockStatusReporter{
resp: &agent.StatusResponse{Cancelled: true},
}
var cancelledID string
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
onCancel: func(id string) { cancelledID = id },
}
task := &Task{ID: "task-cancel-1234567", Status: StatusCompleted}
pr.Track(task)
pr.flush(context.Background())
if cancelledID != "task-cancel-1234567" {
t.Errorf("expected cancel handler called with task ID, got %q", cancelledID)
}
}
func TestProgressReporter_HandleResponsePause(t *testing.T) {
reporter := &mockStatusReporter{
resp: &agent.StatusResponse{Paused: true},
}
var pausedID string
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
onPause: func(id string) { pausedID = id },
}
task := &Task{ID: "task-paused-1234567", Status: StatusCompleted}
pr.Track(task)
pr.flush(context.Background())
if pausedID != "task-paused-1234567" {
t.Errorf("expected pause handler called, got %q", pausedID)
}
}
func TestProgressReporter_HandleResponseDeleteFiles(t *testing.T) {
reporter := &mockStatusReporter{
resp: &agent.StatusResponse{Cancelled: true, DeleteFiles: true},
}
var deletedID string
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
onDeleteFiles: func(id string) { deletedID = id },
}
task := &Task{ID: "task-delete-1234567", Status: StatusCompleted}
pr.Track(task)
pr.flush(context.Background())
if deletedID != "task-delete-1234567" {
t.Errorf("expected deleteFiles handler called, got %q", deletedID)
}
}
func TestProgressReporter_HandleResponseStream(t *testing.T) {
reporter := &mockStatusReporter{
resp: &agent.StatusResponse{StreamRequested: true},
}
var streamID string
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
onStreamRequested: func(id string) { streamID = id },
}
// Task with no stream URL yet
task := &Task{ID: "task-stream-1234567", Status: StatusCompleted}
pr.Track(task)
pr.flush(context.Background())
if streamID != "task-stream-1234567" {
t.Errorf("expected stream handler called, got %q", streamID)
}
}
func TestProgressReporter_HandleResponseWatchingChanged(t *testing.T) {
reporter := &mockStatusReporter{
resp: &agent.StatusResponse{Watching: true},
}
var watchingValue bool
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
onWatchingChanged: func(w bool) { watchingValue = w },
}
task := &Task{ID: "task-watch2-1234567", Status: StatusCompleted}
pr.Track(task)
pr.flush(context.Background())
if !watchingValue {
t.Error("expected watchingChanged called with true")
}
}
func TestProgressReporter_BatchFlush(t *testing.T) {
batcher := &mockBatchReporter{
batchResp: &agent.BatchStatusResponse{
Results: []agent.StatusResponse{{}, {}},
},
}
pr := &ProgressReporter{
reporter: batcher,
interval: time.Second,
isWatching: func() bool { return true },
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
pr.Track(&Task{ID: "task-batch1-1234567", Status: StatusDownloading})
pr.Track(&Task{ID: "task-batch2-1234567", Status: StatusDownloading})
pr.flush(context.Background())
batcher.mu.Lock()
defer batcher.mu.Unlock()
if len(batcher.batchCalls) != 1 {
t.Fatalf("expected 1 batch call, got %d", len(batcher.batchCalls))
}
if len(batcher.batchCalls[0]) != 2 {
t.Errorf("expected 2 updates in batch, got %d", len(batcher.batchCalls[0]))
}
}
func TestProgressReporter_RunStopsOnCancel(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: 50 * time.Millisecond,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
err := pr.Run(ctx)
if err != nil {
t.Errorf("Run should return nil on context cancel, got: %v", err)
}
}
func TestProgressReporter_ReportFinal(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
task := &Task{ID: "task-final-12345678", Status: StatusCompleted}
pr.Track(task)
pr.ReportFinal(context.Background(), task)
reporter.mu.Lock()
defer reporter.mu.Unlock()
if len(reporter.calls) != 1 {
t.Fatalf("expected 1 final report, got %d", len(reporter.calls))
}
// Should be untracked after final report
pr.mu.Lock()
if _, ok := pr.latest["task-final-12345678"]; ok {
t.Error("task should be untracked after ReportFinal")
}
pr.mu.Unlock()
}
func TestProgressReporter_SetHandlers(t *testing.T) {
pr := &ProgressReporter{
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
}
pr.SetCancelHandler(func(id string) {})
pr.SetPauseHandler(func(id string) {})
pr.SetDeleteFilesHandler(func(id string) {})
pr.SetStreamRequestedHandler(func(id string) {})
pr.SetWatchingFunc(func() bool { return true })
pr.SetWatchingChangedHandler(func(w bool) {})
if pr.onCancel == nil || pr.onPause == nil || pr.onDeleteFiles == nil ||
pr.onStreamRequested == nil || pr.isWatching == nil || pr.onWatchingChanged == nil {
t.Error("expected all handlers to be set")
}
}
func TestProgressReporter_ControlCheckDue(t *testing.T) {
reporter := &mockStatusReporter{}
pr := &ProgressReporter{
reporter: reporter,
interval: time.Second,
isWatching: func() bool { return false },
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
lastCheckAt: time.Now().Add(-31 * time.Second), // 31s ago - due for control check
}
task := &Task{ID: "task-ctrl-123456789", Status: StatusDownloading}
pr.Track(task)
pr.mu.Lock()
pr.lastReported["task-ctrl-123456789"] = StatusDownloading
pr.mu.Unlock()
pr.flush(context.Background())
reporter.mu.Lock()
defer reporter.mu.Unlock()
if len(reporter.calls) != 1 {
t.Errorf("expected 1 report for control check, got %d", len(reporter.calls))
}
}

View file

@ -0,0 +1,9 @@
//go:build !windows
package engine
import "syscall"
func setReuseAddr(fd uintptr) error {
return syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
}

View file

@ -0,0 +1,9 @@
//go:build windows
package engine
import "syscall"
func setReuseAddr(fd uintptr) error {
return syscall.SetsockoptInt(syscall.Handle(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)
}

View file

@ -297,9 +297,44 @@ func (s *StreamEngine) FileName() string { return s.fileName }
// FileLength returns the total size of the selected file in bytes. // FileLength returns the total size of the selected file in bytes.
func (s *StreamEngine) FileLength() int64 { return s.totalBytes } func (s *StreamEngine) FileLength() int64 { return s.totalBytes }
// FileSize implements FileProvider for StreamServer compatibility.
func (s *StreamEngine) FileSize() int64 { return s.totalBytes }
// BufferTarget returns the buffer threshold in bytes. // BufferTarget returns the buffer threshold in bytes.
func (s *StreamEngine) BufferTarget() int64 { return s.bufferTarget } func (s *StreamEngine) BufferTarget() int64 { return s.bufferTarget }
// PrioritizeTail abre un lector posicionado cerca del final del archivo para
// forzar la descarga anticipada de los metadatos del container (moov atom en
// MP4, seekhead en MKV). Sin esto, VLC busca el final del archivo al abrirlo
// y el lector bloquea indefinidamente si esas piezas aún no están descargadas,
// resultando en pantalla negra en redes lentas o remotas.
//
// Se ejecuta en una goroutine y se cancela cuando ctx expira.
func (s *StreamEngine) PrioritizeTail(ctx context.Context, tailBytes int64) {
if s.file == nil || s.totalBytes <= tailBytes*2 {
return
}
go func() {
reader := s.file.NewReader()
defer reader.Close()
seekPos := s.totalBytes - tailBytes
reader.Seek(seekPos, io.SeekStart) //nolint:errcheck
reader.SetReadahead(tailBytes)
reader.SetContext(ctx)
// Leer continuamente para mantener las piezas priorizadas hasta que
// ctx se cancele o el final del archivo esté completamente descargado.
buf := make([]byte, 32*1024)
for {
_, err := reader.Read(buf)
if err != nil {
return
}
}
}()
}
// Shutdown gracefully closes the torrent and client. // Shutdown gracefully closes the torrent and client.
func (s *StreamEngine) Shutdown(_ context.Context) error { func (s *StreamEngine) Shutdown(_ context.Context) error {
if s.tor != nil { if s.tor != nil {

View file

@ -4,14 +4,23 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"runtime" "runtime"
"strings"
) )
// OpenPlayer attempts to open a media player with the given stream URL. // OpenPlayer attempts to open a media player with the given stream URL.
// Returns the player name and the running command. // Returns the player name and the running command.
// If override is set, it uses that command directly. // 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) { 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 != "" { if override != "" {
cmd := exec.Command(override, url) cmd := exec.Command(override, "--", url)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return override, nil, fmt.Errorf("start %s: %w", override, err) 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) // Try mpv first (best streaming support)
if path, err := exec.LookPath("mpv"); err == nil { 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 { if err := cmd.Start(); err == nil {
return "mpv", cmd, nil return "mpv", cmd, nil
} }
@ -28,7 +37,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try VLC // Try VLC
if path, err := exec.LookPath("vlc"); err == nil { if path, err := exec.LookPath("vlc"); err == nil {
cmd := exec.Command(path, url) cmd := exec.Command(path, "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "vlc", cmd, nil return "vlc", cmd, nil
} }
@ -36,7 +45,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try cvlc (VLC headless) // Try cvlc (VLC headless)
if path, err := exec.LookPath("cvlc"); err == nil { if path, err := exec.LookPath("cvlc"); err == nil {
cmd := exec.Command(path, url) cmd := exec.Command(path, "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "vlc (headless)", cmd, 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) { 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 { switch runtime.GOOS {
case "linux": case "linux":
if path, err := exec.LookPath("xdg-open"); err == nil { if path, err := exec.LookPath("xdg-open"); err == nil {
@ -60,7 +72,7 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
} }
} }
case "darwin": case "darwin":
cmd := exec.Command("/usr/bin/open", url) cmd := exec.Command("/usr/bin/open", "--", url)
if err := cmd.Start(); err == nil { if err := cmd.Start(); err == nil {
return "browser", cmd, 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") 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://")
}

File diff suppressed because it is too large Load diff

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