Compare commits

..

No commits in common. "main" and "v0.3.2" have entirely different histories.
main ... v0.3.2

175 changed files with 3330 additions and 26979 deletions

View file

@ -1,105 +0,0 @@
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

@ -1,61 +0,0 @@
# 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

@ -1,118 +0,0 @@
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 \
.

131
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,131 @@
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: Checkout go-client (local replace dependency)
uses: actions/checkout@v6
with:
repository: torrentclaw/go-client
path: ../go-client
- 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: Checkout go-client (local replace dependency)
uses: actions/checkout@v6
with:
repository: torrentclaw/go-client
path: ../go-client
- 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: Checkout go-client (local replace dependency)
uses: actions/checkout@v6
with:
repository: torrentclaw/go-client
path: ../go-client
- 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.1.6
coverage:
name: Coverage
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Checkout go-client (local replace dependency)
uses: actions/checkout@v6
with:
repository: torrentclaw/go-client
path: ../go-client
- 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@v5
with:
file: ./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: Checkout go-client (local replace dependency)
uses: actions/checkout@v6
with:
repository: torrentclaw/go-client
path: ../go-client
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Run go vet
run: go vet ./...

162
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,162 @@
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,12 +36,6 @@ Thumbs.db
# GoReleaser
dist/
dist-ffbinaries/
# Docker
tmp/
config/
dist-ffbinaries/
# Claude Code: keep entirely local, do not track
.claude/

View file

@ -1,11 +1,10 @@
version: "2"
run:
timeout: 5m
linters:
default: none
enable:
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
@ -15,49 +14,31 @@ linters:
- copyloopvar
- durationcheck
- errname
- errorlint
- exhaustive
settings:
gosec:
excludes:
- G104 # Unhandled errors in fire-and-forget
- G112 # Slowloris — local-only servers
- G115 # Integer overflow — CLI-safe conversions
- G204 # Subprocess with variable — intentional for player/extractor launch
- G301 # Directory perms > 0750 — standard for user dirs
- G302 # File perms > 0600 — resume files need 0644
- G304 # File inclusion via variable — config paths are trusted
- G306 # WriteFile perms > 0600 — binaries need 0755
- G702 # Command injection via taint — self-update uses trusted URLs
- G703 # Path traversal via taint — internal paths only
- G704 # SSRF via taint — user-configured URLs
- G706 # Log injection via taint — internal log lines
exhaustive:
default-signifies-exhaustive: true
exclusions:
paths:
- dist
rules:
# Allow misspell "cancelled" — API constant matching server
- linters:
- misspell
text: "Cancell"
# Ignore nilerr in intentional log-and-continue patterns
- linters:
- nilerr
path: "(clean|scanner|usenet|engine)"
# Ignore staticcheck style suggestions (QF/S/SA4/SA9)
- linters:
- staticcheck
text: "^(QF|S1|SA4011|SA9003)"
# Ignore gosec G101 (hardcoded creds) in tests
- linters:
- gosec
path: _test\.go
formatters:
enable:
- gofmt
- goimports
exclusions:
paths:
- misspell
- nilerr
- prealloc
- unconvert
- unparam
- wastedassign
linters-settings:
gosec:
excludes:
- G104 # Allow unhandled errors in fire-and-forget (notifications)
errcheck:
exclude-functions:
- (*os/exec.Cmd).Start # Fire-and-forget for notifications
exhaustive:
default-signifies-exhaustive: true
misspell:
locale: US
issues:
exclude-dirs:
- dist
max-issues-per-linter: 50
max-same-issues: 5

View file

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

View file

View file

@ -5,584 +5,53 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.15] - 2026-05-27
## [Unreleased]
### Added
- **sentry**: enhance error handling by skipping user input errors in CaptureError
- Init wizard with daemon install step (`unarr init`, replaces `unarr setup`)
- Interactive config menu with 7 categories (`unarr config [category]`)
- 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
- Renamed `internal/commands/` to `internal/cmd/`
- **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
## [0.1.0] - 2025-02-14
### Added
- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
### CI/CD
- port workflows from .github/ to .forgejo/ (Forgejo Actions)
### Fixed
- **daemon**: defensive IsClosed check in watchSessionReady poll loop
- **daemon**: use parent ctx for MarkSessionReady so cancel propagates
- **release**: move gitea_urls to top-level (goreleaser v2 schema)
## [0.9.13] - 2026-05-27
### Added
- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13)
- **agent**: send full transcoder diagnostic in register payload (0.9.12)
### Fixed
- **daemon**: defer probeCancel so a panic mid-diagnostic still releases ctx
### Other
- **release**: add ship.sh end-to-end pipeline as GH Actions backup
- **skills**: add /publish slash command + allow .claude/ in git
## [0.9.11] - 2026-05-27
### Added
- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
### Changed
- **hls**: critico-driven hardening of fase 3.2
### Fixed
- **cors**: allow play from .to / staging / onion mirrors
- **library**: classify resolution by width + height, not height alone
- **transcode**: make preset libx264-only + restore quality opt-in
### Other
- **release**: 0.9.11
## [0.9.8] - 2026-05-27
### Fixed
- **upgrade**: break auto-apply restart loop (0.9.8)
## [0.9.7] - 2026-05-26
### Added
- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
## [0.9.6] - 2026-05-26
### Added
- **daemon**: auto-apply upgrades when server signals (0.9.6)
## [0.9.5] - 2026-05-26
### Added
- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
## [0.9.4] - 2026-05-26
### Added
- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
## [0.9.3] - 2026-05-26
### Added
- **usenet**: warn at startup when par2 or extractor is missing
### Fixed
- **engine**: truncate errorMessage before reporting status
- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
## [0.9.2] - 2026-05-22
### Added
- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
## [0.9.1] - 2026-05-21
### Added
- **mirror**: update fallback URLs to use IPFS and remove GitHub Pages
### Fixed
- **security**: bump golang.org/x deps and add container CVE scan gate
### Other
- **release**: 0.9.1
## [0.9.0] - 2026-05-21
### Added
- **agent**: add mirror failover, agent client refactor, status 401 detection
- **vpn**: local config_file for self-hosted/personal VPN testing
- **vpn**: split-tunnel torrent traffic through managed WireGuard
### CI/CD
- deploy install scripts to GitHub Pages
### Documentation
- **docker**: refresh Docker Hub README + sync description in CI
### Fixed
- **security**: CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
- **security**: UPnP opt-in, bounded SSE reader, signed self-update
- **security**: harden HLS session IDs, /health disclosure, archive password handling
- **upgrade**: fetch releases from TorrentClaw app, not GitHub
### Other
- **pages**: add .nojekyll to disable Jekyll processing
- **pages**: set custom domain unarr.torrentclaw.com
- **release**: 0.9.0
## [0.8.1] - 2026-05-08
### 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
- 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
[0.1.0]: https://github.com/torrentclaw/unarr/releases/tag/v0.1.0

1
CNAME
View file

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

View file

@ -1,200 +0,0 @@
# 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,28 +16,9 @@ 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/
# ---- Runtime stage ----
FROM alpine:3.22
FROM alpine:3.21
# 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
RUN apk add --no-cache ca-certificates tzdata
# 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

View file

@ -1,170 +0,0 @@
# 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

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

View file

@ -1,4 +1,4 @@
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push
.PHONY: all build test lint coverage clean fmt vet check install-hooks
BINARY = unarr
SENTRY_DSN ?=
@ -19,13 +19,10 @@ test:
lint:
golangci-lint run ./...
## Run tests with coverage report (excludes CLI layer — cmd/ is glue code)
COVER_PKGS = $(shell go list ./... | grep -v '/cmd')
## Run tests with coverage report
coverage:
go test -race -coverprofile=coverage.out -covermode=atomic $(COVER_PKGS)
@echo "──────────────────────────────────────"
@go tool cover -func=coverage.out | tail -1
@echo "──────────────────────────────────────"
go test -race -coverprofile=coverage.out -covermode=atomic ./...
go tool cover -func=coverage.out
go tool cover -html=coverage.out -o coverage.html
## Format code
@ -48,42 +45,6 @@ install-hooks:
install:
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
clean:
rm -f $(BINARY) coverage.out coverage.html

247
README.md
View file

@ -3,17 +3,13 @@
> **⚠️ Beta** — unarr is under active development. Features may change, and bugs are expected. [Report issues here](https://github.com/torrentclaw/unarr/issues).
[![CI](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml/badge.svg)](https://github.com/torrentclaw/unarr/actions/workflows/ci.yml)
[![Latest Release](https://img.shields.io/github/v/release/torrentclaw/unarr)](https://github.com/torrentclaw/unarr/releases)
[![Go Report Card](https://goreportcard.com/badge/github.com/torrentclaw/unarr)](https://goreportcard.com/report/github.com/torrentclaw/unarr)
[![Coverage](https://img.shields.io/codecov/c/github/torrentclaw/unarr)](https://codecov.io/gh/torrentclaw/unarr)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-scanned-brightgreen?logo=virustotal)](https://github.com/torrentclaw/unarr/releases)
[![Docker Pulls](https://img.shields.io/docker/pulls/torrentclaw/unarr)](https://hub.docker.com/r/torrentclaw/unarr)
[![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)
The single-binary terminal client for torrent, debrid, and usenet downloads. **Free and open source.**
Powerful terminal tool for torrent search and management. **Free and open source.**
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.
Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
<!-- GIF demo placeholder -->
<!-- ![unarr Demo](docs/demo.gif) -->
@ -171,9 +167,6 @@ unarr start
| `unarr status` | Show daemon status and active downloads |
| `unarr daemon install` | Install as system service (systemd/launchd) |
| `unarr daemon uninstall` | Remove the system service |
| `unarr vpn status` | Show managed-VPN config and live tunnel state |
| `unarr vpn enable` | Turn the managed VPN on |
| `unarr vpn disable` | Turn the managed VPN off |
### System & Diagnostics
@ -283,53 +276,6 @@ The daemon connects via WebSocket for instant task delivery, with automatic HTTP
- Linux: `~/.config/systemd/user/unarr.service` (systemd)
- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd)
## VPN
unarr can route your **downloads** through a managed WireGuard VPN, so peers and
trackers see the VPN server's IP instead of yours. It runs entirely in userspace
(wireguard-go + a gVisor netstack) — **no root, no `wg-quick`, no changes to your
OS routing table**.
Requires a **PRO+ plan with the VPN add-on**. Set it up at
[torrentclaw.com/vpn](https://torrentclaw.com/vpn).
```bash
# Turn it on (writes [downloads.vpn] enabled = true to your config)
unarr vpn enable
# Restart the daemon so it brings the tunnel up at startup
unarr daemon restart # or: unarr start (if not installed as a service)
# Check it's working — shows the exit server when the tunnel is up
unarr vpn status
# Verify your account is provisioned (queries the API)
unarr vpn status --check
# Turn it off again
unarr vpn disable
```
**Split-tunnel — read this:** only the torrent client's traffic goes through the
VPN. Your browser, `curl`, and every other app keep using your **real IP** — that
is by design. To check the VPN is working, look at `unarr vpn status` (or the
peer/announce IP), **not** your browser's "what's my IP". To protect your other
devices (phone, laptop), use the **OpenVPN credentials** from your profile — those
support ~10 concurrent devices and do **not** share the agent's WireGuard slot.
**When does it fetch the config?** Once, at daemon startup. There's no periodic
refresh — after changing your exit server in the web panel or re-provisioning,
restart the daemon to pick it up. If the fetch fails the daemon logs a `[vpn]`
line and downloads in the clear (never refuses to run).
**Self-hosted / personal VPN:** instead of the managed config, point unarr at a
local WireGuard `.conf`:
```toml
[downloads.vpn]
config_file = "/path/to/wg.conf" # takes precedence over `enabled`
```
## Diagnostics
```bash
@ -343,58 +289,6 @@ unarr self-update --force # reinstall even if up to date
`unarr doctor` checks: config file, API key, server connectivity (with latency), agent registration, download directory, disk space, and version.
### Updating unarr
unarr supports three update paths. Pick whichever fits your workflow.
**1. Manual self-update (always available).**
```bash
unarr self-update # interactive update to latest
unarr self-update --force # reinstall same version
unarr self-update --allow-unsigned # accept releases without checksum signature
```
The CLI downloads the new release archive over HTTPS (from
`torrentclaw.com/releases/download/v<ver>/`), verifies SHA-256, swaps the
binary in place (`.backup` kept next to it), and restarts the systemd
user unit if the daemon is running.
**2. Auto-apply on server signal (default, since 0.9.6).**
When you press **"Force update now"** on the web (Settings → Agent → Force
update), the server sets a flag your daemon polls every sync (~3 s). On
the next sync the daemon downloads the new binary, replaces itself, and
exits — `systemd Restart=always` respawns on the new version. No SSH, no
terminal access required. Works headless on NAS / Docker.
The button shows an amber warning if your agent is below 0.9.6 (older
daemons see the signal but only log "run unarr update" — the operator
must run the command manually that one time).
**Opt out of auto-apply.** Some users prefer reviewing CHANGELOG before
applying. Disable in `config.toml`:
```toml
[daemon]
auto_upgrade = false
```
With `auto_upgrade = false`, pressing the web button still flags your
agent (so the daemon logs the new version on next sync), but the daemon
will not download / replace anything — you run `unarr self-update` when
you're ready.
**3. Docker auto-restart with a new tag.**
```bash
docker pull torrentclaw/unarr:latest
docker compose up -d
```
Tags published: `latest`, `0.9`, `0.9.7`, ... — pin to a minor (`0.9`)
for opt-in patch updates without surprises.
## Clean
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting.
@ -476,7 +370,6 @@ tv_shows_dir = "~/Media/TV Shows"
[daemon]
poll_interval = "30s"
heartbeat_interval = "30s"
auto_upgrade = true # apply server-flagged upgrades in-place (since 0.9.6)
[notifications]
enabled = true
@ -485,142 +378,6 @@ enabled = true
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 override config file values:

View file

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

View file

@ -1,79 +0,0 @@
# 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"

17
go.mod
View file

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

34
go.sum
View file

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

View file

@ -12,64 +12,25 @@ import (
)
// Client communicates with the /api/internal/agent/* endpoints.
//
// The client owns a MirrorPool: when a request fails with a transient
// network error (DNS, refused, timeout, 5xx) it rotates to the next mirror
// and retries up to `len(mirrors)-1` times so a single agent run survives
// a primary-domain takedown without user intervention.
type Client struct {
pool *MirrorPool
baseURL string
apiKey string
httpClient *http.Client
// 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 targeting a single base URL.
// Equivalent to NewClientWithMirrors(baseURL, nil, ...) — kept for callers
// that don't yet care about mirror failover.
// NewClient creates an agent API 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{
pool: NewMirrorPool(baseURL, extras),
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
// 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.
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
var resp RegisterResponse
@ -79,6 +40,27 @@ func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterRe
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.
func (c *Client) Deregister(ctx context.Context, agentID string) error {
req := struct {
@ -91,41 +73,13 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
return nil
}
// ReportUpgradeResult tells the server the outcome of a previously requested
// upgrade so the server can clear `upgrade_requested`. Without this call the
// flag stays sticky and the daemon would re-trigger applyAutoUpgrade on every
// sync after upgrade — even for "already on target version" no-ops.
func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, success bool, version, errMsg string) error {
req := struct {
AgentID string `json:"agentId"`
// ReportUpgradeResult reports the outcome of a self-upgrade attempt.
func (c *Client) ReportUpgradeResult(ctx context.Context, result UpgradeResult) error {
var resp struct {
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)
if err := c.doPost(ctx, "/api/internal/agent/upgrade-result", result, &resp); err != nil {
return fmt.Errorf("report upgrade: %w", err)
}
return nil
}
@ -139,25 +93,6 @@ func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*Status
return &resp, nil
}
// BatchReportStatus sends multiple status updates in a single request.
func (c *Client) BatchReportStatus(ctx context.Context, updates []StatusUpdate) (*BatchStatusResponse, error) {
var resp BatchStatusResponse
if err := c.doPost(ctx, "/api/internal/agent/status", BatchStatusRequest{Updates: updates}, &resp); err != nil {
return nil, fmt.Errorf("batch report status: %w", err)
}
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
// ---------------------------------------------------------------------------
@ -174,35 +109,30 @@ func (c *Client) SearchNzbs(ctx context.Context, params NzbSearchParams) (*NzbSe
// DownloadNzb downloads the NZB file for the given nzbId.
// Returns the raw NZB XML bytes.
func (c *Client) DownloadNzb(ctx context.Context, nzbID string) ([]byte, error) {
path := fmt.Sprintf("/api/internal/agent/nzb-download?nzbId=%s", nzbID)
url := fmt.Sprintf("/api/internal/agent/nzb-download?nzbId=%s", nzbID)
var out []byte
err := c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+url, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
return nil, 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)
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
return nil, fmt.Errorf("nzb download error %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit
if err != nil {
return fmt.Errorf("read nzb: %w", err)
return nil, fmt.Errorf("read nzb: %w", err)
}
out = data
return nil
})
return out, err
return data, nil
}
// GetUsenetCredentials fetches NNTP connection credentials.
@ -242,81 +172,22 @@ func (c *Client) BatchDownload(ctx context.Context, req BatchDownloadRequest) (*
}
// 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) {
var resp LibrarySyncResponse
if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/library-sync", req, &resp); err != nil {
if err := c.doPost(ctx, "/api/internal/agent/library-sync", req, &resp); err != nil {
return nil, fmt.Errorf("library sync: %w", err)
}
return &resp, nil
}
// 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.
// doPost sends a JSON POST request and decodes the response.
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)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
return c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(jsonBody))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
@ -324,20 +195,18 @@ func (c *Client) doPostWith(ctx context.Context, hc *http.Client, path string, b
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(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)
})
}
// doGet sends a GET request and decodes the response.
func (c *Client) doGet(ctx context.Context, path string, dst any) error {
return c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
@ -351,45 +220,6 @@ func (c *Client) doGet(ctx context.Context, path string, dst any) error {
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
}
var lastErr error
for i := 0; i < attempts; i++ {
base := c.baseURL()
err := fn(base)
if err == nil {
return nil
}
lastErr = err
if !IsTransient(err) {
return err
}
// Last attempt: don't bother rotating, just surface the error.
if i == attempts-1 {
break
}
next, rotated := c.pool.Rotate()
if !rotated {
break
}
_ = next // mirror rotation logging is left to higher layers (cmd/) so the
// pool stays log-free for tests.
}
return lastErr
}
func (c *Client) setHeaders(req *http.Request) {
@ -409,14 +239,14 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error {
// Try to parse as JSON error
var errResp ErrorResponse
if json.Unmarshal(body, &errResp) == nil && errResp.Error != "" {
return &HTTPError{StatusCode: resp.StatusCode, Message: errResp.Error}
return fmt.Errorf("API error %d: %s", resp.StatusCode, errResp.Error)
}
// Non-JSON response (e.g. HTML error page) — truncate to something readable
msg := string(body)
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)
}
return &HTTPError{StatusCode: resp.StatusCode, Message: msg}
return fmt.Errorf("API error %d: %s", resp.StatusCode, msg)
}
if dst != nil {

View file

@ -3,11 +3,9 @@ package agent
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestRegister(t *testing.T) {
@ -74,6 +72,70 @@ 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) {
var received StatusUpdate
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -111,6 +173,22 @@ 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) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
@ -201,527 +279,107 @@ func TestUserAgent(t *testing.T) {
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"))
}
json.NewEncoder(w).Encode(RegisterResponse{Success: true})
json.NewEncoder(w).Encode(HeartbeatResponse{Success: true})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr/0.2.0")
c.Register(context.Background(), RegisterRequest{AgentID: "x"})
c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "x"})
}
func TestDeregister(t *testing.T) {
var received struct {
AgentID string `json:"agentId"`
}
func TestHeartbeatWithUpgradeSignal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/deregister" {
t.Errorf("path = %s", r.URL.Path)
json.NewEncoder(w).Encode(HeartbeatResponse{
Success: true,
Upgrade: &UpgradeSignal{Version: "2.0.0"},
})
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.Heartbeat(context.Background(), HeartbeatRequest{AgentID: "agent-1"})
if err != nil {
t.Fatalf("Heartbeat failed: %v", err)
}
if resp.Upgrade == nil {
t.Fatal("expected upgrade signal, got nil")
}
if resp.Upgrade.Version != "2.0.0" {
t.Errorf("upgrade version = %q, want 2.0.0", resp.Upgrade.Version)
}
}
func TestHeartbeatWithoutUpgradeSignal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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-1"})
if err != nil {
t.Fatalf("Heartbeat failed: %v", err)
}
if resp.Upgrade != nil {
t.Errorf("expected no upgrade signal, got %+v", resp.Upgrade)
}
}
func TestReportUpgradeResult(t *testing.T) {
var received UpgradeResult
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/internal/agent/upgrade-result" {
t.Errorf("path = %s, want /api/internal/agent/upgrade-result", 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(StatusResponse{Success: true})
json.NewEncoder(w).Encode(struct{ Success bool }{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()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.BatchReportStatus(context.Background(), []StatusUpdate{
{TaskID: "t1", Status: "downloading"},
{TaskID: "t2", Status: "completed"},
err := c.ReportUpgradeResult(context.Background(), UpgradeResult{
AgentID: "agent-1",
Success: true,
Version: "2.0.0",
})
if err != nil {
t.Fatalf("BatchReportStatus failed: %v", err)
t.Fatalf("ReportUpgradeResult failed: %v", err)
}
if len(resp.Results) != 2 {
t.Fatalf("expected 2 results, got %d", len(resp.Results))
if received.AgentID != "agent-1" {
t.Errorf("agentId = %q, want agent-1", received.AgentID)
}
if !resp.Results[1].Cancelled {
t.Error("expected result[1].Cancelled=true")
}
}
func TestSearchNzbs(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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()
c := NewClient(srv.URL, "test-key", "unarr-test")
resp, err := c.SearchNzbs(context.Background(), NzbSearchParams{Query: "Movie"})
if err != nil {
t.Fatalf("SearchNzbs failed: %v", err)
}
if len(resp.Results) != 1 {
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 {
if !received.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)
if received.Version != "2.0.0" {
t.Errorf("version = %q, want 2.0.0", received.Version)
}
}
func TestSyncLibrary(t *testing.T) {
func TestReportUpgradeResultFailure(t *testing.T) {
var received UpgradeResult
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,
})
json.NewEncoder(w).Encode(struct{ Success bool }{Success: 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
err := c.ReportUpgradeResult(context.Background(), UpgradeResult{
AgentID: "agent-1",
Success: false,
Error: "checksum mismatch",
})
if err != nil {
t.Fatalf("Sync failed: %v", err)
t.Fatalf("ReportUpgradeResult failed: %v", err)
}
if len(resp.NewTasks) != 1 {
t.Fatalf("expected 1 new task, got %d", len(resp.NewTasks))
if received.Success {
t.Error("expected success=false")
}
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")
if received.Error != "checksum mismatch" {
t.Errorf("error = %q, want 'checksum mismatch'", received.Error)
}
}

View file

@ -2,17 +2,11 @@ package agent
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/torrentclaw/unarr/internal/upgrade"
)
// DaemonConfig holds daemon runtime settings.
@ -21,102 +15,62 @@ type DaemonConfig struct {
AgentName string
Version string
DownloadDir string
StreamPort int // port for the HTTP stream server
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)
PollInterval time.Duration
HeartbeatInterval time.Duration
}
// Daemon manages agent registration and the sync loop.
// Daemon manages the main loop: register, heartbeat, poll tasks.
type Daemon struct {
cfg DaemonConfig
client *Client
sync *SyncClient
state *LocalState
transport Transport
// Callbacks — set by cmd/daemon.go before calling Run.
// Callbacks
OnTasksClaimed func(tasks []Task)
OnStreamRequested func(req StreamRequest)
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
OnUpgradeRequested func(version string)
OnControlAction func(action, taskID string)
// State
User UserInfo
Features FeatureFlags
Info AgentInfo
State DaemonState
lastNotifiedVersion string
upgradeInProgress bool
heartbeatFailures int
// Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
// into DaemonState on every write so external tools (`unarr vpn status`) see it.
vpnActive bool
vpnMode string
vpnServer string
// Callbacks for state tracking (set by cmd/daemon.go)
GetActiveCount func() int
GetCleanableBytes func() int64
// CloudFlare Quick Tunnel public URL; folded into DaemonState + heartbeat
// so the web can prefer it over Tailscale/LAN for in-browser playback.
funnelURL string
// Exposed tickers for hot-reload
PollTicker *time.Ticker
HeartbeatTicker *time.Ticker
// Watching tracks whether a user is viewing download progress in the web UI.
Watching atomic.Bool
// ScanNow triggers an immediate library scan.
ScanNow chan struct{}
// pollNow triggers an immediate poll (e.g. on resume)
pollNow chan struct{}
}
// NewDaemon creates a daemon with an HTTP client for sync-based communication.
func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
state := NewLocalState()
// NewDaemon creates a daemon with the given transport.
// Use NewHTTPTransport for HTTP-only, or NewHybridTransport for WS+HTTP.
func NewDaemon(cfg DaemonConfig, transport Transport) *Daemon {
if cfg.PollInterval == 0 {
cfg.PollInterval = 30 * time.Second
}
if cfg.HeartbeatInterval == 0 {
cfg.HeartbeatInterval = 30 * time.Second
}
return &Daemon{
cfg: cfg,
client: client,
state: state,
sync: NewSyncClient(client, cfg, state),
ScanNow: make(chan struct{}, 1),
transport: transport,
pollNow: make(chan struct{}, 1),
}
}
// SyncClient returns the sync client for external wiring.
func (d *Daemon) SyncClient() *SyncClient { return d.sync }
// SetVPNState records the managed-VPN split-tunnel state so it's reflected in the
// daemon state file (read by `unarr vpn status`). Call before Run.
func (d *Daemon) SetVPNState(active bool, mode, server string) {
d.vpnActive = active
d.vpnMode = mode
d.vpnServer = server
}
// SetFunnelURL records the CloudFlare Quick Tunnel hostname so it's reflected
// in the daemon state file (read by `unarr funnel status`) and in heartbeat
// requests (so the web prefers it over Tailscale/LAN). Pass "" to clear.
func (d *Daemon) SetFunnelURL(url string) {
d.funnelURL = url
d.State.FunnelURL = url
WriteState(&d.State)
}
// UpdateStreamPort updates the stream port reported in sync requests.
func (d *Daemon) UpdateStreamPort(port int) {
d.cfg.StreamPort = port
d.sync.cfg.StreamPort = port
}
// Transport returns the configured transport.
func (d *Daemon) Transport() Transport { return d.transport }
// 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 {
req := RegisterRequest{
AgentID: d.cfg.AgentID,
@ -125,50 +79,15 @@ func (d *Daemon) Register(ctx context.Context) error {
Arch: runtime.GOARCH,
Version: d.cfg.Version,
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 {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
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)
}
resp, err := d.transport.Register(ctx, req)
if err != nil {
return fmt.Errorf("register: %w (after %d retries)", err, maxRetries)
return fmt.Errorf("register: %w", err)
}
d.User = resp.User
@ -188,18 +107,13 @@ func (d *Daemon) Register(ctx context.Context) error {
PID: os.Getpid(),
StartedAt: now,
MethodStats: make(map[string]int),
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
}
WriteState(&d.State)
return nil
}
// Run registers the agent and starts the sync loop.
// Blocks until ctx is cancelled.
// Run starts the main daemon loop. Blocks until ctx is cancelled.
func (d *Daemon) Run(ctx context.Context) error {
// Register
if err := d.Register(ctx); err != nil {
@ -208,93 +122,147 @@ func (d *Daemon) Run(ctx context.Context) error {
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
log.Printf("Polling every %s, heartbeat every %s", d.cfg.PollInterval, d.cfg.HeartbeatInterval)
// Usenet needs par2 (segment repair) + an extractor (RAR/7z) on the host.
// Without par2, a single bad segment corrupts the file silently; without
// an extractor, RAR-packed downloads can't be unpacked. Warn loudly at
// startup so the operator installs them before the first download fails.
if d.Features.Usenet {
if _, err := exec.LookPath("par2"); err != nil {
log.Printf("[usenet] WARNING: par2 not found in PATH — corrupted segments cannot be repaired and extraction may fail. Install par2 (apt install par2 / brew install par2).")
}
_, unrarErr := exec.LookPath("unrar")
_, sevenZErr := exec.LookPath("7z")
if unrarErr != nil && sevenZErr != nil {
log.Printf("[usenet] WARNING: no archive extractor (unrar or 7z) found — RAR-packed downloads cannot be unpacked. Install unrar or 7z.")
}
}
d.HeartbeatTicker = time.NewTicker(d.cfg.HeartbeatInterval)
defer d.HeartbeatTicker.Stop()
// Wire sync callbacks
d.sync.OnNewTasks = func(tasks []Task) {
if d.OnTasksClaimed != nil {
d.OnTasksClaimed(tasks)
}
}
d.sync.OnControl = func(action, taskID string, deleteFiles bool) {
if d.OnControlAction != nil {
d.OnControlAction(action, taskID, deleteFiles)
}
}
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")
d.PollTicker = time.NewTicker(d.cfg.PollInterval)
defer d.PollTicker.Stop()
heartbeatTicker := d.HeartbeatTicker
pollTicker := d.PollTicker
// Initial poll immediately
d.poll(ctx)
eventsCh := d.transport.Events()
for {
select {
case d.ScanNow <- struct{}{}:
default:
case <-ctx.Done():
log.Println("Daemon shutting down...")
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)
}
func (d *Daemon) heartbeat(ctx context.Context) {
req := HeartbeatRequest{
AgentID: d.cfg.AgentID,
Name: d.cfg.AgentName,
Version: d.cfg.Version,
OS: runtime.GOOS,
DownloadDir: d.cfg.DownloadDir,
}
d.sync.GetVPNState = func() (bool, string, string) {
return d.vpnActive, d.vpnMode, d.vpnServer
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
req.DiskTotalBytes = total
}
d.sync.GetFunnelURL = func() string {
return d.funnelURL
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)
}
d.sync.OnSyncSuccess = func() {
return
}
if d.heartbeatFailures > 0 {
log.Printf("Heartbeat recovered after %d failures", d.heartbeatFailures)
d.heartbeatFailures = 0
}
// Update state file
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
d.State.ActiveTasks = d.GetActiveCount()
}
WriteState(&d.State)
// Check for upgrade signal from server
if resp.Upgrade != nil && resp.Upgrade.Version != "" && !d.upgradeInProgress {
d.upgradeInProgress = true
log.Printf("Upgrade requested by server: %s → %s", d.cfg.Version, resp.Upgrade.Version)
if d.OnUpgradeRequested != nil {
go d.OnUpgradeRequested(resp.Upgrade.Version)
}
}
}
// handleEvent processes a server-initiated event from the WebSocket transport.
func (d *Daemon) handleEvent(event ServerEvent) {
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)
}
}
// Start sync loop (blocks)
return d.sync.Run(ctx)
case "upgrade":
if event.Upgrade != nil && event.Upgrade.Version != "" && !d.upgradeInProgress {
d.upgradeInProgress = true
log.Printf("Upgrade requested via WebSocket: %s → %s", d.cfg.Version, event.Upgrade.Version)
if d.OnUpgradeRequested != nil {
go d.OnUpgradeRequested(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")
}
}
// TriggerSync requests an immediate sync cycle.
func (d *Daemon) TriggerSync() {
d.sync.TriggerSync()
// 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
}
}
// Deregister notifies the server of graceful shutdown.
func (d *Daemon) Deregister() {
// ClearUpgradeInProgress resets the upgrade flag so a retry can be attempted.
func (d *Daemon) ClearUpgradeInProgress() {
d.upgradeInProgress = false
}
func (d *Daemon) deregister() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := d.client.Deregister(ctx, d.cfg.AgentID); err != nil {
err := d.transport.Deregister(ctx, d.cfg.AgentID)
if err != nil {
log.Printf("Deregister failed: %v", err)
} else {
log.Println("Agent deregistered")
@ -302,81 +270,26 @@ func (d *Daemon) Deregister() {
RemoveState()
}
// applyAutoUpgrade downloads the target version and exits so the service
// supervisor (systemd Restart=always on Linux) respawns on the new binary.
// Triggered by the server's upgrade signal — opt-in flag set by the user from
// the web UI; the daemon never auto-upgrades on a passive version bump.
//
// Reports the outcome to /api/internal/agent/upgrade-result so the server
// clears `upgrade_requested`. Without this report the flag stays sticky and
// the daemon would loop on every sync — including the no-op case where it's
// already on the target version.
func (d *Daemon) applyAutoUpgrade(targetVersion string) {
currentClean := strings.TrimPrefix(d.cfg.Version, "v")
targetClean := strings.TrimPrefix(targetVersion, "v")
// No-op: server signal arrived but we're already running the target. This
// happens when the daemon restarts after a previous auto-upgrade before
// reportUpgradeResult cleared the flag, or when the operator manually
// installed the same version off-band. Skip Execute (which would also
// no-op) AND skip os.Exit, but DO clear the flag — otherwise we loop.
if currentClean == targetClean {
log.Printf("[upgrade] already on v%s — clearing server flag", currentClean)
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelR()
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, currentClean, ""); err != nil {
log.Printf("[upgrade] report-result failed (will retry on next signal): %v", err)
}
func (d *Daemon) poll(ctx context.Context) {
resp, err := d.transport.ClaimTasks(ctx, d.cfg.AgentID)
if err != nil {
log.Printf("Poll failed: %v", err)
return
}
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
OnProgress: func(msg string) {
log.Printf("[upgrade] %s", msg)
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
result := upgrader.Execute(ctx, targetVersion)
if !result.Success {
log.Printf("[upgrade] auto-upgrade failed: %v", result.Error)
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelR()
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, false, targetClean, errMsg); err != nil {
log.Printf("[upgrade] report-result failed: %v", err)
}
return
}
log.Printf("[upgrade] upgraded v%s → v%s; reporting result + exiting so service supervisor restarts on new binary",
result.OldVersion, result.NewVersion)
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, result.NewVersion, ""); err != nil {
log.Printf("[upgrade] report-result failed: %v", err)
}
cancelR()
time.Sleep(500 * time.Millisecond)
os.Exit(0)
}
d.Info.LastPollAt = time.Now()
// 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
if len(resp.Tasks) > 0 {
log.Printf("Claimed %d task(s)", len(resp.Tasks))
if d.OnTasksClaimed != nil {
d.OnTasksClaimed(resp.Tasks)
}
}
// Handle stream requests for completed downloads
if d.OnStreamRequested != nil {
for _, sr := range resp.StreamRequests {
d.OnStreamRequested(sr)
}
}
return false
}

View file

@ -1,25 +0,0 @@
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

@ -1,62 +0,0 @@
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

@ -1,232 +0,0 @@
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

@ -1,172 +0,0 @@
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

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

View file

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

View file

@ -1,311 +0,0 @@
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)
}
}

View file

@ -1,542 +0,0 @@
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)
}
}

View file

@ -1,136 +0,0 @@
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

@ -1,270 +0,0 @@
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

@ -0,0 +1,53 @@
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
// ReportUpgradeResult reports upgrade outcome.
ReportUpgradeResult(ctx context.Context, result UpgradeResult) 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

@ -0,0 +1,295 @@
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")
}
// 6. Send upgrade result
err = tr.ReportUpgradeResult(ctx, UpgradeResult{
AgentID: "e2e-agent",
Success: true,
Version: "2.0.0",
})
if err != nil {
t.Fatalf("ReportUpgradeResult: %v", err)
}
// Verify server received all messages
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if len(receivedMessages) < 4 {
t.Fatalf("expected at least 4 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", "upgrade-result"}
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

@ -0,0 +1,50 @@
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) 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)
}
func (t *HTTPTransport) ReportUpgradeResult(ctx context.Context, result UpgradeResult) error {
return t.client.ReportUpgradeResult(ctx, result)
}
// Client returns the underlying HTTP client for direct use if needed.
func (t *HTTPTransport) Client() *Client { return t.client }

View file

@ -0,0 +1,226 @@
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)
}
// ReportUpgradeResult delegates to the active transport.
func (h *HybridTransport) ReportUpgradeResult(ctx context.Context, result UpgradeResult) error {
if h.mode.Load() == "ws" {
if err := h.ws.ReportUpgradeResult(ctx, result); err != nil {
h.switchToHTTP()
return h.http.ReportUpgradeResult(ctx, result)
}
return nil
}
return h.http.ReportUpgradeResult(ctx, result)
}
// ── 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

@ -0,0 +1,445 @@
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

@ -0,0 +1,360 @@
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, _, err := dialer.DialContext(ctx, wsURLWithKey, header)
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()
}
// ReportUpgradeResult sends upgrade result to the DO.
func (t *WSTransport) ReportUpgradeResult(_ context.Context, result UpgradeResult) error {
msg := struct {
Type string `json:"type"`
Success bool `json:"success"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}{
Type: "upgrade-result",
Success: result.Success,
Version: result.Version,
Error: result.Error,
}
return t.send(msg)
}
// ── 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,9 +1,6 @@
package agent
import (
"fmt"
"time"
)
import "time"
// RegisterRequest is sent by the CLI on startup to register itself.
type RegisterRequest struct {
@ -15,37 +12,6 @@ type RegisterRequest struct {
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"`
// 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.
@ -78,6 +44,17 @@ type UsenetServerInfo struct {
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.
type Task struct {
ID string `json:"id"`
@ -94,18 +71,12 @@ type Task struct {
ReplacePath string `json:"replacePath,omitempty"` // File to replace after download (upgrade mode)
LibraryItemID int `json:"libraryItemId,omitempty"` // Library item being upgraded
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")
}
// FilePath is the on-disk path of the file the agent is being asked
// to operate on. Currently used by mode=seed_file to know which
// arbitrary file to wrap as a single-file torrent for browser
// streaming; populated by the server from libraryItem.filePath.
FilePath string `json:"filePath,omitempty"`
// TasksResponse wraps the array of tasks returned by the server.
type TasksResponse struct {
Tasks []Task `json:"tasks"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
}
// StreamRequest is a request to stream a completed download from disk.
@ -127,11 +98,7 @@ type StatusUpdate struct {
FileName string `json:"fileName,omitempty"`
FilePath string `json:"filePath,omitempty"`
StreamURL string `json:"streamUrl,omitempty"`
StreamReady bool `json:"streamReady,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.
@ -142,18 +109,12 @@ type StatusResponse struct {
Paused bool `json:"paused,omitempty"`
DeleteFiles bool `json:"deleteFiles,omitempty"`
StreamRequested bool `json:"streamRequested,omitempty"`
Watching bool `json:"watching,omitempty"`
}
// BatchStatusRequest wraps multiple status updates in a single request.
type BatchStatusRequest struct {
Updates []StatusUpdate `json:"updates"`
}
// BatchStatusResponse wraps per-task results from the batch endpoint.
type BatchStatusResponse struct {
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"`
}
// UpgradeSignal tells the agent to upgrade to a specific version.
@ -161,23 +122,20 @@ type UpgradeSignal struct {
Version string `json:"version"`
}
// UpgradeResult is sent by the agent after an upgrade attempt.
type UpgradeResult struct {
AgentID string `json:"agentId"`
Success bool `json:"success"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}
// ErrorResponse is returned on API errors.
type ErrorResponse struct {
Error string `json:"error"`
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.
type AgentInfo struct {
ID string
@ -185,6 +143,7 @@ type AgentInfo struct {
User UserInfo
Features FeatureFlags
StartedAt time.Time
LastPollAt time.Time
ActiveTasks int
}
@ -309,7 +268,6 @@ type LibrarySyncRequest struct {
Items []LibrarySyncItem `json:"items"`
ScanPath string `json:"scanPath"`
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.
@ -341,101 +299,3 @@ type LibrarySyncResponse struct {
Matched int `json:"matched"`
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"`
}

View file

@ -1,396 +0,0 @@
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,9 +1,6 @@
package arr
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
@ -85,158 +82,3 @@ 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

@ -1,23 +0,0 @@
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

@ -341,3 +341,4 @@ func CleanableBytes() int64 {
return total
}

View file

@ -55,6 +55,7 @@ func TestFileSize_NonExistent(t *testing.T) {
}
}
func TestRunClean_DryRun(t *testing.T) {
err := runClean(true, false, false)
if err != nil {

View file

@ -14,7 +14,7 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
var configCategories = []string{"downloads", "organization", "library", "notifications", "device", "region", "connection", "advanced"}
var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"}
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
@ -25,7 +25,6 @@ func newConfigCmd() *cobra.Command {
Categories:
downloads Download directory, method, speed limits, concurrency
organization Auto-sort into Movies / TV Shows folders
library Library scan settings and file deletion permissions
notifications Desktop notifications
device Agent name
region Country and language
@ -96,7 +95,6 @@ func runConfigMenu(category string) error {
Options(
huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
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("Device — agent name", "device"),
huh.NewOption("Region — country & language", "region"),
@ -133,8 +131,6 @@ func runCategory(cfg *config.Config, category string) error {
return configDownloads(cfg)
case "organization":
return configOrganization(cfg)
case "library":
return configLibrary(cfg)
case "notifications":
return configNotifications(cfg)
case "device":
@ -315,23 +311,23 @@ func configConnection(cfg *config.Config) error {
).Run()
}
func configLibrary(cfg *config.Config) error {
func configAdvanced(cfg *config.Config) error {
return huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title("Allow file deletion from web UI?").
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.Library.AllowDelete),
huh.NewInput().
Title("Poll interval").
Description("How often to check for new tasks (e.g. 30s, 1m)").
Value(&cfg.Daemon.PollInterval).
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()
}
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 ──────────────────────────────────────────────────────
func validateSpeed(s string) error {

View file

@ -1,55 +0,0 @@
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

@ -1,335 +0,0 @@
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,14 +6,10 @@ import (
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"text/template"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
const systemdTemplate = `[Unit]
@ -26,10 +22,11 @@ Type=simple
ExecStart={{.BinPath}} start
Restart=always
RestartSec=10
User={{.User}}
Environment=HOME={{.Home}}
[Install]
WantedBy=default.target
WantedBy=multi-user.target
`
const launchdTemplate = `<?xml version="1.0" encoding="UTF-8"?>
@ -127,8 +124,6 @@ func runDaemonInstall() error {
return installSystemd(data, green)
case "darwin":
return installLaunchd(data, green)
case "windows":
return installWindowsTask(data, green)
default:
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
}
@ -234,17 +229,6 @@ func runDaemonUninstall() error {
os.Remove(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:
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
}
@ -253,44 +237,3 @@ func runDaemonUninstall() error {
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

@ -1,93 +0,0 @@
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,26 +17,6 @@ import (
"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 {
var method string
@ -68,10 +48,6 @@ daemon instead: 'unarr start'.`,
}
func runDownload(input, method string) error {
return runDownloadWithDeps(input, method, defaultDownloadDeps)
}
func runDownloadWithDeps(input, method string, deps downloadDeps) error {
cfg := loadConfig()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
@ -108,7 +84,7 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
fmt.Println()
// Create torrent downloader
torrentDl, err := deps.newTorrentDl(engine.TorrentConfig{
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: outputDir,
MetadataTimeout: 15 * time.Minute,
StallTimeout: 10 * time.Minute,
@ -121,20 +97,19 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
// Create a dummy reporter (no API reporting for one-shot)
reporter := engine.NewProgressReporter(
deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
5*time.Second,
)
debridDl := deps.newDebridDl()
debridDl := engine.NewDebridDownloader()
manager := deps.newManager(engine.ManagerConfig{
manager := engine.NewManager(engine.ManagerConfig{
MaxConcurrent: 1,
OutputDir: outputDir,
Organize: engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
OutputDir: outputDir,
},
}, reporter, torrentDl, debridDl)

View file

@ -1,397 +0,0 @@
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")
}
}

View file

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

View file

@ -1,69 +0,0 @@
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,8 +360,18 @@ func runInit(apiURLOverride string) error {
fmt.Println()
// Features summary
if line := formatFeatures(resp.Features); line != "" {
cyan.Printf(" Available: %s\n", line)
features := []string{}
if resp.Features.Torrent {
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 {

View file

@ -1,187 +0,0 @@
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
}

View file

@ -1,204 +0,0 @@
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

@ -1,96 +0,0 @@
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

@ -1,176 +0,0 @@
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,14 +3,12 @@
package cmd
import (
"errors"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
"github.com/fatih/color"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
@ -21,8 +19,7 @@ type ReloadableConfig struct {
}
// startReloadWatcher listens for SIGUSR1 and reloads config.
// 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.
// Only intervals are hot-reloadable (speeds require torrent client restart).
func startReloadWatcher(rc *ReloadableConfig) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGUSR1)
@ -31,50 +28,26 @@ func startReloadWatcher(rc *ReloadableConfig) {
for range sigCh {
log.Println("Received SIGUSR1, reloading config...")
_, err := config.Load("")
cfg, err := config.Load("")
if err != nil {
log.Printf("Config reload failed: %v", err)
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")
}
}()
}
// 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,15 +2,7 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"strconv"
"github.com/fatih/color"
"github.com/torrentclaw/unarr/internal/agent"
)
import "github.com/torrentclaw/unarr/internal/agent"
// ReloadableConfig holds a reference to the daemon for hot-reload.
type ReloadableConfig struct {
@ -19,25 +11,3 @@ type ReloadableConfig struct {
// startReloadWatcher is a no-op on Windows (no SIGUSR1 support).
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

@ -6,10 +6,9 @@ import (
"github.com/fatih/color"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
tc "github.com/torrentclaw/go-client"
)
var (
@ -26,19 +25,15 @@ var (
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Version: Version,
Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
Long: `unarr is a terminal-native client that downloads torrents, debrid links,
and usenet (NZB) all from the same binary. It streams content straight
to mpv/vlc with sequential piece prioritization, transcodes on the fly via
ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
organizes your library into Movies/TV folders. Run it one-shot or as a
long-running daemon with a built-in WireGuard split-tunnel and remote
playback over Cloudflare Funnel.
Short: "unarr — torrent search and management",
Long: `unarr is a powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content,
find streaming providers, and manage your media collection all from your terminal.
Get started:
unarr init First-time configuration wizard
unarr download <magnet|hash> Grab a torrent one-shot
unarr search "breaking bad" Search for content
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
@ -47,10 +42,6 @@ Source: https://github.com/torrentclaw/unarr`,
if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
// Self-updater fetches releases from the configured host (default
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
// UNARR_API_URL all route updates correctly.
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
},
SilenceUsage: true,
SilenceErrors: true,
@ -59,7 +50,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
&cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
@ -73,8 +64,6 @@ Source: https://github.com/torrentclaw/unarr`,
// Getting Started
initCmd := newInitCmd()
initCmd.GroupID = "start"
loginCmd := newLoginCmd()
loginCmd.GroupID = "start"
configCmd := newConfigCmd()
configCmd.GroupID = "start"
migrateCmd := newMigrateCmd()
@ -107,22 +96,14 @@ Source: https://github.com/torrentclaw/unarr`,
statusCmd.GroupID = "daemon"
daemonCmd := newDaemonCmd()
daemonCmd.GroupID = "daemon"
vpnCmd := newVPNCmd()
vpnCmd.GroupID = "daemon"
funnelCmd := newFunnelCmd()
funnelCmd.GroupID = "daemon"
// System & Diagnostics
statsCmd := newStatsCmd()
statsCmd.GroupID = "system"
doctorCmd := newDoctorCmd()
doctorCmd.GroupID = "system"
probeHWAccelCmd := newProbeHWAccelCmd()
probeHWAccelCmd.GroupID = "system"
cleanCmd := newCleanCmd()
cleanCmd.GroupID = "system"
mirrorsCmd := newMirrorsCmd()
mirrorsCmd.GroupID = "system"
selfUpdateCmd := newSelfUpdateCmd()
selfUpdateCmd.GroupID = "system"
versionCmd := newVersionCmd()
@ -137,7 +118,6 @@ Source: https://github.com/torrentclaw/unarr`,
rootCmd.AddCommand(
// Getting Started
initCmd,
loginCmd,
configCmd,
migrateCmd,
// Search & Discovery
@ -154,21 +134,22 @@ Source: https://github.com/torrentclaw/unarr`,
stopCmd,
statusCmd,
daemonCmd,
vpnCmd,
funnelCmd,
// System & Diagnostics
statsCmd,
doctorCmd,
probeHWAccelCmd,
cleanCmd,
mirrorsCmd,
selfUpdateCmd,
versionCmd,
completionCmd,
// Library
scanCmd,
// Alias: upgrade → self-update
newUpgradeCmd(),
// Stubs for future commands
newStubCmd("upgrade", "Find a better version of a torrent"),
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,7 +9,6 @@ import (
"sort"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
@ -41,16 +40,11 @@ to see available quality upgrades.`,
}
if len(args) == 0 {
cfg := loadConfig()
paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
if len(paths) == 0 {
return fmt.Errorf("usage: unarr scan <path>\n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'")
if cfg.Library.ScanPath != "" {
args = append(args, cfg.Library.ScanPath)
} else {
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)
},
@ -171,7 +165,6 @@ func syncToServer(ctx context.Context, cfg config.Config, cache *library.Library
totalSynced := 0
totalMatched := 0
totalRemoved := 0
syncStartedAt := time.Now().UTC().Format(time.RFC3339)
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
@ -187,7 +180,6 @@ func syncToServer(ctx context.Context, cfg config.Config, cache *library.Library
Items: batch,
ScanPath: cache.Path,
IsLastBatch: isLast,
SyncStartedAt: syncStartedAt,
})
if err != nil {
return fmt.Errorf("sync failed: %w", err)
@ -241,7 +233,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue
}
res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
res := library.ResolveResolution(item.MediaInfo.Video.Height)
if res == "" {
res = "other"
}

View file

@ -3,17 +3,19 @@ package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"syscall"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/upgrade"
)
func newSelfUpdateCmd() *cobra.Command {
var force bool
var allowUnsigned bool
cmd := &cobra.Command{
Use: "self-update",
@ -21,35 +23,29 @@ func newSelfUpdateCmd() *cobra.Command {
Long: `Download and install the latest version of unarr.
Checks GitHub for the latest release, verifies the checksum, and
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).`,
replaces the current binary. A backup is kept at <binary>.backup.`,
Example: ` unarr self-update
unarr self-update --force
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
unarr self-update --force`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force, allowUnsigned)
return runSelfUpdate(force)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
return cmd
}
func runSelfUpdate(force, allowUnsigned bool) error {
func runSelfUpdate(force bool) 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(" unarr self-update")
fmt.Println()
// Check latest version
fmt.Print(" Checking latest version... ")
ctx := context.Background()
latest, err := upgrade.CheckLatest(ctx)
@ -77,7 +73,6 @@ func runSelfUpdate(force, allowUnsigned bool) error {
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
AllowUnsigned: allowUnsigned,
OnProgress: func(msg string) {
fmt.Printf(" %s\n", msg)
},
@ -94,25 +89,37 @@ func runSelfUpdate(force, allowUnsigned bool) error {
if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath)
}
fmt.Println()
// Auto-restart daemon if it is running, otherwise the live process keeps
// serving the old version (heartbeat reports old version → web gates
// features against the wrong version).
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
fmt.Println()
fmt.Printf(" → Daemon running (PID %d), restarting to load new version...\n", state.PID)
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
// If running as daemon, re-exec to restart with new binary
// For interactive use, just suggest restarting
if isRunningAsDaemon() {
fmt.Println(" Restarting daemon with new version...")
binPath, err := os.Executable()
if err != nil {
return fmt.Errorf("could not determine executable path: %w", err)
}
green.Println(" ✓ Daemon restarted")
execErr := syscall.Exec(binPath, os.Args, os.Environ())
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
}
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,27 +1,20 @@
package cmd
import (
"context"
"errors"
"fmt"
"runtime"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/upgrade"
)
func newStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show daemon status, configuration, and update availability",
Long: `Display the current state of unarr: version, configuration, daemon status,
disk usage, and whether an update is available.
Short: "Show daemon status and active downloads",
Long: `Display the current state of the daemon, active downloads, and recent activity.
When the daemon is running, also displays uptime, active downloads, and stats.`,
Shows the configured agent name, download directory, and preferred method.
When the daemon is running, also displays active downloads and their progress.`,
Example: ` unarr status`,
RunE: func(cmd *cobra.Command, args []string) error {
return runStatus()
@ -32,232 +25,27 @@ When the daemon is running, also displays uptime, active downloads, and stats.`,
func runStatus() 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)
fmt.Println()
bold.Printf(" unarr %s\n", Version)
dim.Printf(" %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Println()
cfg := loadConfig()
// ── Configuration ──
if cfg.Auth.APIKey == "" {
yellow.Println(" ⚠ Not configured. Run 'unarr init' first.")
dim.Println(" Not configured. Run 'unarr init' first.")
fmt.Println()
return nil
}
// ── Account (async fetch) ──
type accountResult struct {
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()
cyan.Println(" Configuration")
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(" Agent: %s (%s)\n", cfg.Agent.Name, cfg.Agent.ID[:8]+"...")
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")
}
}
dim.Println(" Daemon not running. Start with 'unarr start'")
dim.Println(" (Live status will be shown here when daemon is running)")
fmt.Println()
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,7 +4,6 @@ import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
@ -18,20 +17,6 @@ import (
"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 {
var (
port int
@ -71,10 +56,6 @@ download directory (or system temp if not configured).`,
}
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()
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
@ -102,7 +83,7 @@ func runStreamWithDeps(input string, port int, noOpen bool, playerCmd string, de
}
// Create engine
eng, err := deps.newStreamEngine(engine.StreamConfig{
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: dataDir,
Port: port,
MetaTimeout: 60 * time.Second,
@ -146,14 +127,14 @@ func runStreamWithDeps(input string, port int, noOpen bool, playerCmd string, de
}
// Start HTTP server
srv := deps.newStreamServer(port)
if err := srv.Listen(ctx); err != nil {
srv := engine.NewStreamServer(eng, port)
streamURL, err := srv.Start(ctx)
if err != nil {
eng.Shutdown(context.Background())
return fmt.Errorf("start server: %w", err)
}
srv.SetFile(eng, "cli-stream")
fmt.Printf(" URL: %s\n", srv.URL())
fmt.Printf(" URL: %s\n", streamURL)
fmt.Println()
// Buffer before opening player
@ -178,15 +159,15 @@ func runStreamWithDeps(input string, port int, noOpen bool, playerCmd string, de
// Open player
if !noOpen {
playerName, _, openErr := deps.openPlayer(srv.URL(), playerCmd)
playerName, _, openErr := engine.OpenPlayer(streamURL, playerCmd)
if openErr != nil {
yellow.Printf(" Could not open player: %s\n", openErr)
fmt.Printf(" Open this URL in your player: %s\n", srv.URL())
fmt.Printf(" Open this URL in your player: %s\n", streamURL)
} else {
green.Printf(" Opened in %s\n", playerName)
}
} else {
fmt.Printf(" Open this URL in your player: %s\n", srv.URL())
fmt.Printf(" Open this URL in your player: %s\n", streamURL)
}
fmt.Println()

View file

@ -14,80 +14,34 @@ import (
"github.com/torrentclaw/unarr/internal/ui"
)
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.
// streamRegistry tracks active stream tasks and servers for cancellation.
var streamRegistry = struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
servers map[string]*engine.StreamServer // servers for active download streams
}{
cancels: make(map[string]context.CancelFunc),
servers: make(map[string]*engine.StreamServer),
}
// cancelStreamContexts cancels all active stream goroutines (download engines, etc.).
// Does NOT touch the persistent server — call srv.ClearFile() separately if needed.
func cancelStreamContexts() {
streamRegistry.mu.Lock()
cancels := make(map[string]context.CancelFunc, len(streamRegistry.cancels))
for k, v := range streamRegistry.cancels {
cancels[k] = v
delete(streamRegistry.cancels, k)
}
streamRegistry.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
// isStreamingTask returns true if there is an active stream goroutine for the given task.
func isStreamingTask(taskID string) bool {
streamRegistry.mu.Lock()
defer streamRegistry.mu.Unlock()
_, ok := streamRegistry.cancels[taskID]
return ok
}
// cancelStreamTask cancels a specific stream goroutine.
// cancelStreamTask cancels a running stream task and shuts down any stream server.
func cancelStreamTask(taskID string) {
streamRegistry.mu.Lock()
cancel, ok := streamRegistry.cancels[taskID]
delete(streamRegistry.cancels, taskID)
streamRegistry.mu.Unlock()
if ok {
if cancel, ok := streamRegistry.cancels[taskID]; ok {
cancel()
delete(streamRegistry.cancels, taskID)
}
if srv, ok := streamRegistry.servers[taskID]; ok {
srv.Shutdown(context.Background())
delete(streamRegistry.servers, taskID)
}
streamRegistry.mu.Unlock()
}
// 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) {
// handleStreamTask manages a streaming task lifecycle outside the Manager.
// It creates a StreamEngine, buffers, starts an HTTP 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) {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
@ -99,10 +53,6 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
streamRegistry.mu.Lock()
delete(streamRegistry.cancels, at.ID)
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)
@ -143,37 +93,31 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
return
}
// 4. Set file on the persistent stream server (instant, no port binding)
srv.SetFile(eng, at.ID)
task.StreamURL = srv.URLsJSON()
log.Printf("[%s] stream ready: %s (url: %s)", at.ID[:8], eng.FileName(), srv.URL())
// Pre-descargar los últimos 5 MB del archivo para que el moov atom (MP4)
// 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)
// 4. Start HTTP server
srv := engine.NewStreamServer(eng, 0)
streamURL, err := srv.Start(ctx)
if err != nil {
task.ErrorMessage = "start HTTP server: " + err.Error()
task.Transition(engine.StatusFailed)
return
}
defer srv.Shutdown(context.Background())
// 6. Progress loop until download completes or cancelled
// 5. Report stream URL — the reporter will send this to the web
task.StreamURL = streamURL
log.Printf("[%s] stream ready: %s", at.ID[:8], streamURL)
// 6. Progress loop
eng.StartProgressLoop(ctx)
progressTicker := time.NewTicker(3 * time.Second)
defer progressTicker.Stop()
completed := false
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Printf("[%s] stream stopped", at.ID[:8])
return
case <-progressTicker.C:
case <-ticker.C:
p := eng.Progress()
task.UpdateProgress(engine.Progress{
DownloadedBytes: p.DownloadedBytes,
@ -185,7 +129,7 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
})
// Terminal progress
if !completed && p.TotalBytes > 0 {
if p.TotalBytes > 0 {
pct := int(float64(p.DownloadedBytes) / float64(p.TotalBytes) * 100)
fmt.Fprintf(os.Stderr, "\r[%s] %d%% — %s/%s @ %s/s peers:%d seeds:%d",
at.ID[:8], pct,
@ -193,12 +137,22 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
p.Peers, p.Seeds)
}
if !completed && p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
fmt.Fprint(os.Stderr, "\r\033[2K")
if p.DownloadedBytes >= p.TotalBytes && p.TotalBytes > 0 {
fmt.Fprint(os.Stderr, "\r\033[2K") // clear progress line
task.Transition(engine.StatusCompleted)
log.Printf("[%s] stream download complete, server stays up until idle (30m)", at.ID[:8])
completed = true
log.Printf("[%s] stream download complete, server stays up for 30m or until cancelled", at.ID[:8])
// Keep HTTP server running so the player can finish reading.
// 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
}
}
}
}

View file

@ -1,165 +0,0 @@
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")
}
}

22
internal/cmd/stubs.go Normal file
View file

@ -0,0 +1,22 @@
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()
},
}
}

View file

@ -1,33 +0,0 @@
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
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.9.15"
var Version = "0.3.0-dev"

View file

@ -1,213 +0,0 @@
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,11 +26,7 @@ type Config struct {
type AuthConfig struct {
APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"`
// Mirrors lists alternate base URLs the agent will fall back to when the
// primary api_url is unreachable. Ordered by preference. Refreshed at
// runtime by `unarr mirrors update` against /api/v1/mirrors so a long-
// running agent survives a primary takedown without a new release.
Mirrors []string `toml:"mirrors"`
WSURL string `toml:"ws_url"` // optional, derived from api_url if empty
}
type AgentConfig struct {
@ -48,79 +44,6 @@ type DownloadConfig struct {
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")
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 {
@ -130,28 +53,10 @@ type OrganizeConfig struct {
}
type DaemonConfig struct {
StatusInterval string `toml:"status_interval"`
// AutoUpgrade gates the daemon's response to a server-flagged upgrade
// (set via the "Force update" button on the web). When true the daemon
// downloads + replaces the binary in-place and exits so the service
// supervisor respawns on the new version. When false the daemon only
// logs "new version available" and the operator must run `unarr update`
// manually. Default: true. Available since unarr 0.9.6.
AutoUpgrade *bool `toml:"auto_upgrade"`
PollInterval string `toml:"poll_interval"`
HeartbeatInterval string `toml:"heartbeat_interval"`
}
// AutoUpgradeEnabled returns the resolved AutoUpgrade flag — defaults to true
// when the user has not set it explicitly. Pointer-vs-bool because Go's
// zero-value bool would collapse "unset" and "false" together.
func (d DaemonConfig) AutoUpgradeEnabled() bool {
if d.AutoUpgrade == nil {
return true
}
return *d.AutoUpgrade
}
func boolPtr(v bool) *bool { return &v }
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
@ -166,66 +71,28 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
}
// 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.
// Default returns a Config with sensible defaults.
func Default() Config {
return Config{
Auth: AuthConfig{
APIURL: "https://torrentclaw.com",
// Default mirror list. Kept in sync with src/lib/mirrors-config.ts
// on the server. Users can override with `unarr mirrors update`,
// which pulls the live list from /api/v1/mirrors.
Mirrors: []string{
"https://torrentclaw.to",
},
},
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
StreamPort: 11818,
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{
Enabled: true,
},
Daemon: DaemonConfig{
PollInterval: "30s",
HeartbeatInterval: "30s",
},
Notifications: NotificationsConfig{
Enabled: true,
},
@ -259,67 +126,25 @@ func Load(path string) (Config, error) {
return cfg, fmt.Errorf("read config: %w", err)
}
meta, err := toml.Decode(string(data), &cfg)
if err != nil {
if err := toml.Unmarshal(data, &cfg); err != nil {
return cfg, fmt.Errorf("parse config: %w", err)
}
applyDefaults(&cfg, meta)
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") {
// Re-apply defaults for zero values that should have defaults
if cfg.Auth.APIURL == "" {
cfg.Auth.APIURL = "https://torrentclaw.com"
}
if !meta.IsDefined("auth", "mirrors") {
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
}
if !meta.IsDefined("downloads", "preferred_method") {
if cfg.Download.PreferredMethod == "" {
cfg.Download.PreferredMethod = "auto"
}
if !meta.IsDefined("downloads", "max_concurrent") {
if cfg.Download.MaxConcurrent == 0 {
cfg.Download.MaxConcurrent = 3
}
if !meta.IsDefined("downloads", "stream_port") {
cfg.Download.StreamPort = 11818
}
if !meta.IsDefined("general", "country") {
if cfg.General.Country == "" {
cfg.General.Country = "US"
}
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.
return cfg, nil
}
// 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" {
t.Errorf("default Country = %q, want US", cfg.General.Country)
}
if cfg.Daemon.StatusInterval != "" {
t.Errorf("default StatusInterval = %q, want empty", cfg.Daemon.StatusInterval)
if cfg.Daemon.HeartbeatInterval != "30s" {
t.Errorf("default HeartbeatInterval = %q, want 30s", cfg.Daemon.HeartbeatInterval)
}
}
@ -190,62 +190,6 @@ 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) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")

View file

@ -10,8 +10,6 @@ import (
"path/filepath"
"sync"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// httpClient is used for debrid HTTPS downloads with a reasonable header timeout.
@ -21,6 +19,13 @@ 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.
// The server handles all debrid provider interaction; this downloader only needs
// a plain HTTPS URL to fetch.
@ -109,6 +114,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
if resp.ContentLength > 0 {
totalBytes = resp.ContentLength
}
existingSize = 0 // Start fresh
case http.StatusPartialContent:
// Resume accepted
startOffset = existingSize
@ -124,7 +130,8 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
var serverSize int64
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
log.Printf("[%s] local size %s != server size %s, re-downloading", agent.ShortID(task.ID), formatBytes(existingSize), formatBytes(serverSize))
log.Printf("[%s] local size %s != server size %s, re-downloading", shortID(task.ID), formatBytes(existingSize), formatBytes(serverSize))
existingSize = 0
resp.Body.Close()
req2, err := http.NewRequestWithContext(dlCtx, http.MethodGet, task.DirectURL, nil)
if err != nil {
@ -144,7 +151,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
break // continue to download loop
}
}
log.Printf("[%s] file already complete: %s (%s)", agent.ShortID(task.ID), fileName, formatBytes(existingSize))
log.Printf("[%s] file already complete: %s (%s)", shortID(task.ID), fileName, formatBytes(existingSize))
return &Result{
FilePath: destPath,
FileName: fileName,
@ -161,10 +168,10 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
var flags int
if startOffset > 0 {
flags = os.O_WRONLY | os.O_APPEND
log.Printf("[%s] resuming debrid download at %s: %s", agent.ShortID(task.ID), formatBytes(startOffset), fileName)
log.Printf("[%s] resuming debrid download at %s: %s", shortID(task.ID), formatBytes(startOffset), fileName)
} else {
flags = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
log.Printf("[%s] starting debrid download: %s", agent.ShortID(task.ID), fileName)
log.Printf("[%s] starting debrid download: %s", shortID(task.ID), fileName)
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
@ -218,7 +225,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
}
log.Printf("[%s] %d%% — %s/%s @ %s/s (debrid)",
agent.ShortID(task.ID), pct,
shortID(task.ID), pct,
formatBytes(downloaded), formatBytes(totalBytes), formatBytes(speed))
p := Progress{
@ -247,7 +254,7 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
}
}
log.Printf("[%s] debrid download complete: %s (%s)", agent.ShortID(task.ID), fileName, formatBytes(downloaded))
log.Printf("[%s] debrid download complete: %s (%s)", shortID(task.ID), fileName, formatBytes(downloaded))
return &Result{
FilePath: destPath,
@ -266,7 +273,7 @@ func (d *DebridDownloader) Pause(taskID string) error {
if ok {
cancel()
log.Printf("[%s] debrid download paused (file kept for resume)", agent.ShortID(taskID))
log.Printf("[%s] debrid download paused (file kept for resume)", shortID(taskID))
}
return nil
}
@ -280,7 +287,7 @@ func (d *DebridDownloader) Cancel(taskID string) error {
if ok {
cancel()
log.Printf("[%s] debrid download cancelled", agent.ShortID(taskID))
log.Printf("[%s] debrid download cancelled", shortID(taskID))
}
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -1,410 +0,0 @@
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

@ -1,134 +0,0 @@
//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

@ -1,361 +0,0 @@
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")
}
}

View file

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

View file

@ -1,273 +0,0 @@
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

@ -1,156 +0,0 @@
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,15 +28,6 @@ type Manager struct {
sem chan struct{}
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.
@ -76,7 +67,7 @@ func (m *Manager) Submit(ctx context.Context, at agent.Task) {
// Force start: bypass semaphore (like Transmission's "Force Start")
if at.ForceStart {
log.Printf("[%s] force start: bypassing queue", agent.ShortID(task.ID))
log.Printf("[%s] force start: bypassing queue", task.ID[:8])
m.wg.Add(1)
go func() {
defer m.wg.Done()
@ -97,12 +88,7 @@ func (m *Manager) Submit(ctx context.Context, at agent.Task) {
m.wg.Add(1)
go func() {
defer m.wg.Done()
defer func() {
<-m.sem
if m.OnTaskDone != nil {
m.OnTaskDone()
}
}()
defer func() { <-m.sem }()
defer taskCancel()
m.processTask(taskCtx, task)
}()
@ -113,11 +99,6 @@ func (m *Manager) HasCapacity() bool {
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.
func (m *Manager) ActiveCount() int {
m.activeMu.RLock()
@ -132,17 +113,6 @@ func (m *Manager) GetTask(taskID string) *Task {
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.
func (m *Manager) ActiveTasks() []*Task {
m.activeMu.RLock()
@ -154,37 +124,6 @@ func (m *Manager) ActiveTasks() []*Task {
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).
func (m *Manager) CancelTask(taskID string) {
m.activeMu.RLock()
@ -211,7 +150,7 @@ func (m *Manager) CancelTask(taskID string) {
task.mu.Unlock()
task.Transition(StatusCancelled)
log.Printf("[%s] cancelled: %s", agent.ShortID(taskID), task.Title)
log.Printf("[%s] cancelled: %s", taskID[:8], task.Title)
}
// PauseTask pauses an active download (keeps partial files for resume).
@ -234,7 +173,7 @@ func (m *Manager) PauseTask(taskID string) {
}
task.Transition(StatusCancelled) // will be re-created as pending by server
log.Printf("[%s] paused: %s", agent.ShortID(taskID), task.Title)
log.Printf("[%s] paused: %s", taskID[:8], task.Title)
}
// CancelAndDeleteFiles cancels a download and removes its files from disk.
@ -261,7 +200,7 @@ func (m *Manager) CancelAndDeleteFiles(taskID string) {
task.mu.Unlock()
task.Transition(StatusCancelled)
log.Printf("[%s] cancelled + files deleted: %s", agent.ShortID(taskID), task.Title)
log.Printf("[%s] cancelled + files deleted: %s", taskID[:8], task.Title)
}
// Wait blocks until all active downloads finish.
@ -322,7 +261,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
}
task.ResolvedMethod = method
log.Printf("[%s] resolved method: %s", agent.ShortID(task.ID), method)
log.Printf("[%s] resolved method: %s", task.ID[:8], method)
// 2. Download
if err := task.Transition(StatusDownloading); err != nil {
@ -346,7 +285,7 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
if err != nil {
// Try fallback
if tryFallback(task, m.downloaders) {
log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err)
log.Printf("[%s] %s failed, trying fallback: %v", task.ID[:8], method, err)
if err := task.Transition(StatusResolving); err == nil {
m.processTaskRetry(ctx, task)
return
@ -356,7 +295,61 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
return
}
m.finalize(ctx, task, result)
// 3. Verify
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.
@ -368,7 +361,7 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
}
task.ResolvedMethod = method
log.Printf("[%s] fallback to: %s", agent.ShortID(task.ID), method)
log.Printf("[%s] fallback to: %s", task.ID[:8], method)
if err := task.Transition(StatusDownloading); err != nil {
m.fail(ctx, task, "transition error: "+err.Error())
@ -390,31 +383,15 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
return
}
m.finalize(ctx, task, result)
}
// 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
}
// Verify + Organize + Complete (same as processTask)
task.Transition(StatusVerifying)
if err := verify(result); err != nil {
m.fail(ctx, task, "verification failed: "+err.Error())
return
}
// 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
}
task.Transition(StatusOrganizing)
finalPath, _ := organize(result, task, m.cfg.Organize)
if finalPath == "" {
finalPath = result.FilePath
}
@ -422,29 +399,8 @@ func (m *Manager) finalize(ctx context.Context, task *Task, result *Result) {
task.FilePath = finalPath
task.mu.Unlock()
// 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)", 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())
task.Transition(StatusCompleted)
log.Printf("[%s] completed (fallback): %s -> %s", task.ID[:8], task.Title, finalPath)
m.reporter.ReportFinal(ctx, task)
}
@ -453,10 +409,9 @@ func (m *Manager) fail(ctx context.Context, task *Task, msg string) {
task.ErrorMessage = msg
task.mu.Unlock()
task.Transition(StatusFailed)
log.Printf("[%s] FAILED: %s — %s", agent.ShortID(task.ID), task.Title, msg)
log.Printf("[%s] FAILED: %s — %s", task.ID[:8], task.Title, msg)
if m.cfg.Notifications {
desktopNotify("Download failed", task.Title+": "+msg)
}
m.recordFinished(task.ToStatusUpdate())
m.reporter.ReportFinal(ctx, task)
}

View file

@ -1,601 +0,0 @@
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,7 +2,6 @@ package engine
import (
"context"
"os"
"testing"
"time"
@ -84,223 +83,3 @@ func TestManagerShutdown(t *testing.T) {
mgr.Shutdown(ctx)
// 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

@ -1,50 +0,0 @@
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,7 +3,6 @@ package engine
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
@ -16,17 +15,6 @@ var (
seasonRegex = regexp.MustCompile(`(?i)S(\d{2})`)
episodeRegex = regexp.MustCompile(`(?i)S(\d{2})E(\d{2})`)
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`) // 1x05 format
pathReplacer = strings.NewReplacer(
"/", "-",
"\\", "-",
":", " -",
"?", "",
"*", "",
"\"", "",
"<", "",
">", "",
"|", "-",
)
)
// OrganizeConfig holds file organization settings.
@ -34,95 +22,36 @@ type OrganizeConfig struct {
Enabled bool
MoviesDir 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.
//
// 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.
// Movies: MoviesDir/Title (Year)/filename.ext
// TV: TVShowsDir/Title/Season XX/filename.ext
func organize(result *Result, task *Task, cfg OrganizeConfig) (string, error) {
if !cfg.Enabled || result == nil || result.FilePath == "" {
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
if title == "" {
title = result.FileName
}
season := detectSeason(result.FileName)
isTV := season != ""
isTV := strings.Contains(strings.ToLower(task.PreferredMethod), "show") ||
seasonRegex.MatchString(result.FileName)
// 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
if isTV && cfg.TVShowsDir != "" {
@ -140,38 +69,34 @@ func organizeLegacy(result *Result, task *Task, cfg OrganizeConfig) (string, err
destDir = filepath.Join(cfg.MoviesDir, movieName)
}
} else {
return result.FilePath, nil
return result.FilePath, nil // no organize dirs configured
}
return moveToDir(result, destDir, "", cfg)
}
// moveToDir handles the actual directory creation and file move, including path traversal check.
// If destFileName is non-empty, the file is renamed to that name (instead of keeping the original).
func moveToDir(result *Result, destDir, destFileName string, cfg OrganizeConfig) (string, error) {
// Validate destination is within an expected base directory
if !((cfg.TVShowsDir != "" && isWithinDir(cfg.TVShowsDir, destDir)) ||
(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)
// Validate destination is within the expected base directory
var baseDir string
if isTV && cfg.TVShowsDir != "" {
baseDir = cfg.TVShowsDir
} else {
baseDir = cfg.MoviesDir
}
if !isWithinDir(baseDir, destDir) {
return "", fmt.Errorf("path traversal blocked: %q escapes %q", destDir, baseDir)
}
if err := os.MkdirAll(destDir, 0o755); err != nil {
return "", fmt.Errorf("create dir: %w", err)
}
fileName := filepath.Base(result.FilePath)
if destFileName != "" {
fileName = destFileName
}
destPath := filepath.Join(destDir, fileName)
destPath := filepath.Join(destDir, filepath.Base(result.FilePath))
// Check if source is a directory (multi-file torrent)
srcInfo, err := os.Stat(result.FilePath)
if err != nil {
return "", fmt.Errorf("stat source: %w", err)
}
if srcInfo.IsDir() {
// For directories: remove existing destination if present, then rename
if _, err := os.Stat(destPath); err == nil {
os.RemoveAll(destPath)
}
@ -181,6 +106,7 @@ func moveToDir(result *Result, destDir, destFileName string, cfg OrganizeConfig)
return destPath, nil
}
// Try rename first (same filesystem), fall back to copy+delete
if err := os.Rename(result.FilePath, destPath); err != nil {
if err := copyFile(result.FilePath, destPath); err != nil {
return "", fmt.Errorf("move file: %w", err)
@ -188,162 +114,9 @@ func moveToDir(result *Result, destDir, destFileName string, cfg OrganizeConfig)
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
}
// 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.
func cleanTitle(title string) string {
// Remove year and everything after common separators

View file

@ -1,560 +0,0 @@
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)
}
})
}
}

View file

@ -1,186 +0,0 @@
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

@ -1,141 +0,0 @@
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

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

View file

@ -1,96 +0,0 @@
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,48 +13,54 @@ import (
type ActionFunc func(taskID string)
// 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 {
ReportStatus(ctx context.Context, update agent.StatusUpdate) (*agent.StatusResponse, error)
}
// BatchStatusReporter extends StatusReporter with batch support.
type BatchStatusReporter interface {
StatusReporter
BatchReportStatus(ctx context.Context, updates []agent.StatusUpdate) (*agent.BatchStatusResponse, error)
}
// WatchingFunc returns whether a user is actively viewing download progress.
type WatchingFunc func() bool
// ProgressReporter aggregates progress from downloads and reports to the API.
// It batches updates to avoid flooding the server.
type ProgressReporter struct {
reporter StatusReporter
interval time.Duration
isWatching WatchingFunc // nil = always report (backwards compatible)
onCancel ActionFunc
onPause ActionFunc
onDeleteFiles ActionFunc
onStreamRequested ActionFunc
onWatchingChanged func(watching bool)
mu sync.Mutex
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.
// Accepts *agent.Client directly (backwards compatible).
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
return &ProgressReporter{
reporter: ac,
interval: interval,
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.
func (r *ProgressReporter) SetCancelHandler(fn ActionFunc) { r.onCancel = fn }
@ -67,15 +73,6 @@ func (r *ProgressReporter) SetDeleteFilesHandler(fn ActionFunc) { r.onDeleteFile
// SetStreamRequestedHandler sets the callback for stream activation.
func (r *ProgressReporter) SetStreamRequestedHandler(fn ActionFunc) { r.onStreamRequested = fn }
// SetWatchingFunc sets the function that checks if someone is viewing downloads.
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.
func (r *ProgressReporter) Track(task *Task) {
r.mu.Lock()
@ -88,7 +85,6 @@ func (r *ProgressReporter) Untrack(taskID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.latest, taskID)
delete(r.lastReported, taskID)
}
// Run starts the periodic flush loop. Blocks until ctx is cancelled.
@ -113,106 +109,24 @@ func (r *ProgressReporter) flush(ctx context.Context) {
for _, t := range r.latest {
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()
// When nobody is watching, only report final states, status transitions,
// and periodic check-ins (every 30s) so we still receive control signals
// (cancel/pause) from the server.
watching := r.isWatching == nil || r.isWatching()
controlCheckDue := time.Since(r.lastCheckAt) >= 30*time.Second
var reportable []*Task
for _, task := range tasks {
status := task.GetStatus()
isFinal := status == StatusCompleted || status == StatusFailed
isActive := status == StatusDownloading || status == StatusVerifying ||
status == StatusOrganizing || status == StatusSeeding ||
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)
}
if status != StatusDownloading && status != StatusVerifying &&
status != StatusOrganizing && status != StatusSeeding &&
status != StatusCompleted && status != StatusFailed {
continue
}
if controlCheckDue {
r.lastCheckAt = time.Now()
}
if len(reportable) == 0 {
return
}
// Use batch when transport supports it
if batcher, ok := r.reporter.(BatchStatusReporter); ok {
r.flushBatch(ctx, batcher, reportable)
return
}
// Fallback: individual requests
for _, task := range reportable {
statusAtReport := task.GetStatus() // capture before HTTP round-trip
update := task.ToStatusUpdate()
resp, err := r.reporter.ReportStatus(ctx, update)
if err != nil {
log.Printf("[%s] progress report failed: %v", task.ID[:8], err)
continue
}
r.mu.Lock()
r.lastReported[task.ID] = statusAtReport
r.mu.Unlock()
r.handleResponse(task, resp)
}
}
func (r *ProgressReporter) flushBatch(ctx context.Context, batcher BatchStatusReporter, tasks []*Task) {
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 {
updates[i] = task.ToStatusUpdate()
statusAtReport[i] = task.GetStatus()
}
resp, err := batcher.BatchReportStatus(ctx, updates)
if err != nil {
log.Printf("batch progress report failed: %v", err)
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)
if len(resp.Results) != len(tasks) {
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 {
if i < len(tasks) {
r.handleResponse(tasks[i], &result)
}
}
}
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)
}
// Handle server-side signals
if resp.Cancelled {
log.Printf("[%s] cancelled by user (via web)", task.ID[:8])
r.Untrack(task.ID)
@ -235,6 +149,7 @@ func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse
r.onStreamRequested(task.ID)
}
}
}
}
// ReportFinal sends a final status update for a completed/failed task.

View file

@ -1,419 +0,0 @@
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

@ -1,9 +0,0 @@
//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

@ -1,9 +0,0 @@
//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)
}

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