Compare commits

..

1 commit

Author SHA1 Message Date
Deivid Soto
6adf1e2c4c feat(mediaserver): Plex/Jellyfin/Emby auto-refresh + .strm instant mode
Sprint 1 — Auto-refresh after download:
- New [[mediaserver]] TOML section with kind/url/token/sections
- mediaserver.Refresh() fans out to Plex (partial via section ID auto-mapping
  from file path prefix) and Jellyfin/Emby (full library scan)
- Manager.OnFinalized callback wired in daemon to trigger refresh after
  organize() completes — keeps engine package free of mediaserver dep
- New unarr mediaserver {setup,list,remove,test} commands
- unarr init wizard offers to configure refresh when a server is detected

Sprint 2 — .strm instant mode (cloud + agent):
- Mode strm-to-library handled in daemon dispatch: writes a one-line .strm
  file pointing to the cloud-resolved debrid HTTPS URL, then triggers refresh
- engine.WriteStrm + StrmDestForTask mirror organize()'s naming so Plex/Jellyfin
  see the expected folder structure (Movies/Title (Year)/, TV Shows/Show/Season XX/)
- Atomic write (temp + rename) so partial files never get indexed
- Reports completed/failed status to the cloud via existing agent client
2026-05-05 20:35:08 +02:00
112 changed files with 1765 additions and 12072 deletions

View file

@ -1,16 +0,0 @@
# Copy this file to .env and fill in your values.
# Then run: docker compose up -d
# Your TorrentClaw API key (required).
# Get it at: https://torrentclaw.com/settings/api-keys
UNARR_API_KEY=tc_your_key_here
# Absolute path to your media / downloads folder.
# This is where finished movies and shows will be saved.
DOWNLOAD_DIR=/home/youruser/Media
# (Optional) Config directory — defaults to ./config next to this file.
# CONFIG_DIR=/home/youruser/.config/unarr
# (Optional) Timezone for logs.
# TZ=Europe/Madrid

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 \
.

View file

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

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

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

5
.gitignore vendored
View file

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

View file

@ -2,14 +2,6 @@ version: 2
project_name: unarr project_name: unarr
# Pre-build hook: fetch static ffmpeg + ffprobe per platform so each
# release tarball ships them adjacent to the unarr binary. ResolveFFmpeg /
# ResolveFFprobe pick them up via the "adjacent to executable" branch — no
# system install or runtime download needed.
before:
hooks:
- bash scripts/download-ffmpeg-static.sh
builds: builds:
- main: ./cmd/unarr/ - main: ./cmd/unarr/
binary: unarr binary: unarr
@ -26,27 +18,13 @@ builds:
- -s -w - -s -w
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}} - -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }} - -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
# Release-signing public key — verified by the self-updater against
# checksums.txt.sig. Empty when not configured; in that case
# signature verification is skipped and a warning is logged.
- -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
archives: archives:
- formats: [tar.gz] - format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format_overrides: format_overrides:
- goos: windows - goos: windows
formats: [zip] format: zip
files:
- LICENSE*
- README*
# Bundle the matching ffmpeg + ffprobe (filename includes .exe on Windows
# because download-ffmpeg-static.sh writes ffmpeg.exe / ffprobe.exe there).
- src: "dist-ffbinaries/{{ .Os }}-{{ .Arch }}/*"
dst: .
strip_parent: true
info:
mode: 0o755
checksum: checksum:
name_template: "checksums.txt" name_template: "checksums.txt"
@ -59,22 +37,6 @@ changelog:
- "^test:" - "^test:"
- "^chore:" - "^chore:"
# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN +
# these URLs and publishes the release there instead of GitHub. Reachable via
# `forgejo` hostname inside the dokploy-network (the runner shares it); for
# local goreleaser runs outside the network, override via env GITEA_API_URL.
#
# In goreleaser v2 `gitea_urls` is a top-level key (was nested under `release`
# in v1).
gitea_urls:
api: http://forgejo:3000/api/v1
download: https://git.torrentclaw.com
skip_tls_verify: false
release:
draft: false
prerelease: auto
# Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN) # Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN # Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
# brews: # brews:

View file

View file

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

1
CNAME
View file

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

View file

@ -1,21 +1,12 @@
# unarr # unarr
**The single binary that replaces your whole *arr stack.** Built-in torrent, Powerful terminal tool for torrent search and management. Search 30+ sources, inspect quality, discover popular content, find streaming providers, and manage downloads — all from your terminal.
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)** **[GitHub](https://github.com/torrentclaw/unarr)** | **[Documentation](https://github.com/torrentclaw/unarr#readme)** | **[Releases](https://github.com/torrentclaw/unarr/releases)**
> Powered by [TorrentClaw](https://torrentclaw.com) — an aggregator that unifies ## Quick Start
> YTS, EZTV, Knaben, Torrentio, Bitmagnet and more, enriched with TMDB metadata
> and a 0100 quality score per release.
--- ### 1. Setup (interactive wizard)
## Quick start
### 1. First-time setup (interactive wizard)
```bash ```bash
docker run -it --rm \ docker run -it --rm \
@ -23,9 +14,6 @@ docker run -it --rm \
torrentclaw/unarr setup 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 ### 2. Run the daemon
```bash ```bash
@ -38,10 +26,6 @@ docker run -d --name unarr \
torrentclaw/unarr torrentclaw/unarr
``` ```
That's it — `unarr` now runs headless, watching for jobs and managing downloads.
---
## Docker Compose ## Docker Compose
```yaml ```yaml
@ -61,54 +45,45 @@ services:
environment: environment:
- TZ=UTC - TZ=UTC
# - UNARR_API_KEY=tc_your_key_here # - UNARR_API_KEY=tc_your_key_here
network_mode: host # recommended for full P2P performance
deploy: deploy:
resources: resources:
limits: limits:
memory: 512M memory: 512M
cpus: "2.0" cpus: "2.0"
network_mode: host
volumes: volumes:
unarr-data: unarr-data:
``` ```
```bash
docker compose run --rm unarr setup # one-time wizard
docker compose up -d # start the daemon
```
---
## Volumes ## Volumes
| Path | Purpose | | Path | Purpose |
|--------------|--------------------------------------------------| |------|---------|
| `/config` | Configuration file (`config.toml`) | | `/config` | Configuration file (`config.toml`) |
| `/downloads` | Finished media downloads | | `/downloads` | Finished media downloads |
| `/data` | Internal state: torrent metadata, cache | | `/data` | Internal state: torrent metadata, cache |
## Environment variables ## Environment Variables
| Variable | Description | Default | | Variable | Description | Default |
|------------------------|--------------------------------------|---------------------------| |----------|-------------|---------|
| `UNARR_API_KEY` | TorrentClaw API key | from config | | `TZ` | Timezone | `UTC` |
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` | | `UNARR_API_KEY` | TorrentClaw API key | from config |
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` | | `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
| `UNARR_CONFIG_DIR` | Config directory | `/config` | | `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` | | `UNARR_CONFIG_DIR` | Config directory | `/config` |
| `TZ` | Timezone | `UTC` | | `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
Any config value can be overridden by its matching `UNARR_*` environment variable.
## Networking ## Networking
**Host mode (recommended)** — full P2P performance, no port mapping: **Host mode** (recommended) gives full P2P performance with no port management:
```yaml ```yaml
network_mode: host network_mode: host
``` ```
**Bridge mode** — more isolated, but you must expose the BitTorrent ports: **Bridge mode** — more isolated, but requires explicit ports:
```yaml ```yaml
ports: ports:
@ -116,7 +91,7 @@ ports:
- "6881-6889:6881-6889/udp" - "6881-6889:6881-6889/udp"
``` ```
## Running commands ## Running Commands
Use `docker exec` for one-off commands while the daemon is running: Use `docker exec` for one-off commands while the daemon is running:
@ -124,77 +99,32 @@ Use `docker exec` for one-off commands while the daemon is running:
docker exec unarr unarr search "inception" --quality 1080p docker exec unarr unarr search "inception" --quality 1080p
docker exec unarr unarr popular --limit 10 docker exec unarr unarr popular --limit 10
docker exec unarr unarr status docker exec unarr unarr status
docker exec unarr unarr doctor # diagnose config / connectivity docker exec unarr unarr doctor
``` ```
--- ## Supported Architectures
| Architecture | Tag |
|-------------|-----|
| `linux/amd64` | `latest`, `0.3`, `0.3.5` |
| `linux/arm64` | `latest`, `0.3`, `0.3.5` |
## Tags ## Tags
| Tag | Description | | Tag | Description |
|----------|--------------------------------------------------| |-----|-------------|
| `latest` | Latest stable release | | `latest` | Latest stable release |
| `X.Y.Z` | Exact version (e.g. `0.9.0`) | | `X.Y.Z` | Specific version (e.g. `0.3.5`) |
| `X.Y` | Latest patch within a minor (e.g. `0.9`) | | `X.Y` | Latest patch for minor version (e.g. `0.3`) |
Pin a tag in production (`torrentclaw/unarr:0.9.0`) for reproducible deploys. ## Image Details
## Supported architectures - **Base image:** Alpine 3.22
- **User:** `unarr` (UID 1000, GID 1000)
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) - **Entrypoint:** `unarr start` (daemon mode)
- **Read-only rootfs** — only mounted volumes are writable - **Read-only filesystem** — only mounted volumes are writable
- **Bundled `ffmpeg` / `ffprobe`** for media inspection — nothing else to install - **No root required** — runs as non-root by default
- **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 ## License
MIT. MIT License — see [LICENSE](https://github.com/torrentclaw/unarr/blob/main/LICENSE) for details.

View file

@ -1,3 +1,25 @@
# ---- ffprobe static binary stage ----
# Download a static ffprobe build from BtbN/FFmpeg-Builds (GitHub CDN, reliable).
FROM alpine:3.22 AS ffprobe-dl
RUN apk add --no-cache curl xz
RUN ARCH=$(uname -m) && \
case "$ARCH" in \
x86_64) SLUG="linux64" ;; \
aarch64) SLUG="linuxarm64" ;; \
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
esac && \
curl -fsSL --retry 3 --retry-delay 5 \
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${SLUG}-gpl.tar.xz" \
-o /tmp/ff.tar.xz && \
mkdir /tmp/ffbuild && \
tar xJ -f /tmp/ff.tar.xz --strip-components=1 -C /tmp/ffbuild/ && \
mv /tmp/ffbuild/bin/ffprobe /usr/local/bin/ffprobe && \
chmod +x /usr/local/bin/ffprobe && \
rm -rf /tmp/ff.tar.xz /tmp/ffbuild && \
ffprobe -version | head -1
# ---- Build stage ---- # ---- Build stage ----
FROM golang:1.25-alpine AS builder FROM golang:1.25-alpine AS builder
@ -18,26 +40,8 @@ RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/inter
# ---- Runtime stage ---- # ---- Runtime stage ----
FROM alpine:3.22 FROM alpine:3.22
# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle /
# BtbN static glibc builds — those need a glibc shim on Alpine and the
# vector-math symbols the GPL builds reference are not satisfiable by
# gcompat. Alpine ships ffmpeg ~7.x which is fine for the HLS transcoding
# pipeline (libx264 + libfdk-aac alternatives included).
RUN apk upgrade --no-cache && \ RUN apk upgrade --no-cache && \
apk add --no-cache ca-certificates tzdata ffmpeg wget apk add --no-cache ca-certificates tzdata
# Bundle cloudflared so `unarr funnel on` (default: on, see config defaults)
# Just Works on a headless container with no first-run network round-trip.
# TARGETARCH is set automatically by Docker buildx during cross-builds.
ARG TARGETARCH=amd64
RUN case "$TARGETARCH" in \
amd64) CF_ARCH=amd64 ;; \
arm64) CF_ARCH=arm64 ;; \
arm) CF_ARCH=armhf ;; \
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
esac && \
wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \
chmod +x /usr/local/bin/cloudflared
# Non-root user (UID 1000 matches typical host user for volume permissions) # Non-root user (UID 1000 matches typical host user for volume permissions)
RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr
@ -49,6 +53,7 @@ RUN mkdir -p /config /downloads /data && \
USER unarr USER unarr
COPY --from=builder /unarr /usr/local/bin/unarr COPY --from=builder /unarr /usr/local/bin/unarr
COPY --from=ffprobe-dl /usr/local/bin/ffprobe /usr/local/bin/ffprobe
# Environment: point config/data to container paths # Environment: point config/data to container paths
ENV UNARR_CONFIG_DIR=/config ENV UNARR_CONFIG_DIR=/config

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

243
README.md
View file

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

View file

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

View file

@ -1,65 +1,48 @@
# unarr — TorrentClaw agent
#
# Quick start:
# 1. Copy this file to any directory.
# 2. Set UNARR_API_KEY to your key (Settings → API Keys on torrentclaw.com).
# 3. Set DOWNLOAD_DIR to your media folder (absolute path).
# 4. Run: docker compose up -d
#
# Get your API key: https://torrentclaw.com/settings/api-keys
# Full docs: https://torrentclaw.com/unarr
services: services:
unarr: unarr:
build:
context: ..
dockerfile: unarr/Dockerfile
image: torrentclaw/unarr:latest image: torrentclaw/unarr:latest
pull_policy: always # always pull on `up` so you stay on the latest release
container_name: unarr container_name: unarr
restart: unless-stopped restart: unless-stopped
user: "1000:1000"
# host network is required for: # Read-only root filesystem — only volumes are writable
# - streaming to reach your TV / mobile / other LAN devices (port 11818) read_only: true
# - HLS transcode server (port 11819) tmpfs:
# - Tailscale connectivity (if you use it) - /tmp:size=64m,mode=1777
# On macOS / Windows Docker Desktop, replace with `ports` mapping (see below).
network_mode: host
environment:
# --- Required ---
- UNARR_API_KEY=${UNARR_API_KEY:?Set UNARR_API_KEY in .env or export it}
# --- Optional ---
# Server URL — change only if you run a self-hosted TorrentClaw instance
- UNARR_API_URL=${UNARR_API_URL:-https://torrentclaw.com}
- TZ=${TZ:-UTC}
volumes: volumes:
# Config: config.toml is auto-created here on first run. # Config: your config.toml lives here
# After first start, edit this file to set organize paths, quality, etc. - ./config:/config
- ${CONFIG_DIR:-./config}:/config # Downloads: finished media goes here
- ~/Media:/downloads
# Downloads: where finished media is saved. # Data: torrent metadata, piece DB, cache
# Set DOWNLOAD_DIR in .env or export it before running.
- ${DOWNLOAD_DIR:?Set DOWNLOAD_DIR to your media folder}:/downloads
# Data: piece-completion DB, HLS cache, DHT nodes.
# Named volume keeps this off your media drive (avoids NFS locking issues).
- unarr-data:/data - unarr-data:/data
# Optional: limit CPU/RAM for transcoding on shared hosts environment:
# deploy: - TZ=${TZ:-UTC}
# resources: # Optional overrides (uncomment to use):
# limits: # - UNARR_API_KEY=tc_your_key_here
# memory: 2G # - UNARR_API_URL=https://torrentclaw.com
# cpus: "4.0"
# --- macOS / Windows alternative (replace network_mode: host above) --- # Resource limits — adjust to your needs
# network_mode: bridge deploy:
resources:
limits:
memory: 512M
cpus: "2.0"
# Torrent P2P needs host network or explicit port range
# Option A: host network (simplest, full P2P performance)
network_mode: host
# Option B: bridge network with port mapping (more isolated)
# Uncomment below and comment out network_mode above:
# ports: # ports:
# - "11818:11818" # direct stream (VLC, download) # - "6881-6889:6881-6889/tcp"
# - "11819:11819" # HLS transcode (web player) # - "6881-6889:6881-6889/udp"
# - "42069:42069" # BitTorrent incoming peers
# Note: streaming will only reach devices on the same machine.
# For LAN / Tailscale playback use a Linux host with network_mode: host.
volumes: volumes:
unarr-data: unarr-data:

13
go.mod
View file

@ -15,9 +15,8 @@ require (
github.com/olekukonko/tablewriter v1.1.4 github.com/olekukonko/tablewriter v1.1.4
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0 github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.43.0 golang.org/x/term v0.41.0
golang.org/x/time v0.15.0 golang.org/x/time v0.15.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
) )
require ( require (
@ -122,14 +121,12 @@ require (
go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/crypto v0.51.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.54.0 // indirect golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.35.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.70.0 // indirect modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect

34
go.sum
View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -29,20 +29,12 @@ type SyncClient struct {
OnNewTasks func(tasks []Task) OnNewTasks func(tasks []Task)
OnControl func(action, taskID string, deleteFiles bool) OnControl func(action, taskID string, deleteFiles bool)
OnStreamRequest func(req StreamRequest) OnStreamRequest func(req StreamRequest)
OnStreamSession func(sess StreamSession)
OnUpgrade func(version string) OnUpgrade func(version string)
OnScan func() OnScan func()
OnWatchingChange func(watching bool) OnWatchingChange func(watching bool)
OnSyncSuccess func() // called after each successful sync (e.g. to update state file) OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
GetFreeSlots func() int GetFreeSlots func() int
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks 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. // OnDeleteFiles is called when the server requests file deletion from disk.
// It should delete the files and return the IDs of successfully deleted items. // It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int OnDeleteFiles func(items []LibraryDeleteRequest) []int
@ -162,12 +154,6 @@ func (sc *SyncClient) buildRequest() SyncRequest {
if sc.GetFreeSlots != nil { if sc.GetFreeSlots != nil {
req.FreeSlots = sc.GetFreeSlots() 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. // Flush confirmed deletions from previous cycle.
// Once flushed, remove IDs from deleteInFlight — the server will stop sending // Once flushed, remove IDs from deleteInFlight — the server will stop sending
// them after this sync, so deduplication protection is no longer needed. // them after this sync, so deduplication protection is no longer needed.
@ -205,13 +191,6 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
} }
} }
// HLS streaming sessions.
for _, ws := range resp.StreamSessions {
if sc.OnStreamSession != nil {
sc.OnStreamSession(ws)
}
}
// Upgrade // Upgrade
if resp.Upgrade != nil && resp.Upgrade.Version != "" && sc.OnUpgrade != nil { if resp.Upgrade != nil && resp.Upgrade.Version != "" && sc.OnUpgrade != nil {
sc.OnUpgrade(resp.Upgrade.Version) sc.OnUpgrade(resp.Upgrade.Version)

View file

@ -215,56 +215,3 @@ func TestLocalState_EmptySnapshot(t *testing.T) {
t.Errorf("expected 0 tasks, got %d", len(snap)) 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

@ -18,34 +18,6 @@ type RegisterRequest struct {
StreamPort int `json:"streamPort,omitempty"` StreamPort int `json:"streamPort,omitempty"`
LanIP string `json:"lanIp,omitempty"` LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"` TailscaleIP string `json:"tailscaleIp,omitempty"`
// Transcode capabilities — let the web side suggest a smarter quality
// before the player even starts. HWAccel is the picked backend
// ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is
// the largest output resolution the agent can encode comfortably; for
// software-only ffmpeg this is 1080p, with a real GPU encoder it goes
// up to 2160p.
HWAccel string `json:"hwAccel,omitempty"`
MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"`
// Diagnostic surface filled by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal so users can
// see *why* their HWAccel landed on "none" without running
// `unarr probe-hwaccel` locally — most commonly the ffmpeg binary
// shipped without HW encoders (linuxbrew, brew's default formula).
FFmpegVersion string `json:"ffmpegVersion,omitempty"`
FFmpegPath string `json:"ffmpegPath,omitempty"`
HWEncoders []string `json:"hwEncoders,omitempty"`
HWDevices []string `json:"hwDevices,omitempty"`
// Managed-VPN split-tunnel state. The web tracks which agent holds the single
// WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent
// connection); other agents are told to use OpenVPN on their host instead.
// VPNActive has no omitempty: false is a meaningful state (tunnel down), not
// "unset" — the server must see it to release the slot.
VPNActive bool `json:"vpnActive"`
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled; the web prefers it over
// Tailscale/LAN for in-browser playback because it works on any network.
FunnelURL string `json:"funnelUrl,omitempty"`
} }
// RegisterResponse is returned by the server after registration. // RegisterResponse is returned by the server after registration.
@ -100,12 +72,6 @@ type Task struct {
Episode *int `json:"episode,omitempty"` // Episode number Episode *int `json:"episode,omitempty"` // Episode number
ContentYear *int `json:"contentYear,omitempty"` // Year from TMDB (avoids regex on torrent title) 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") 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"`
} }
// StreamRequest is a request to stream a completed download from disk. // StreamRequest is a request to stream a completed download from disk.
@ -129,9 +95,6 @@ type StatusUpdate struct {
StreamURL string `json:"streamUrl,omitempty"` StreamURL string `json:"streamUrl,omitempty"`
StreamReady bool `json:"streamReady,omitempty"` StreamReady bool `json:"streamReady,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"`
// mode=seed_file: agent computes the info_hash from the local file
// and reports it back so the web player can target /stream/<hash>.
InfoHash string `json:"infoHash,omitempty"`
} }
// StatusResponse is returned by the status endpoint. // StatusResponse is returned by the status endpoint.
@ -364,15 +327,6 @@ type SyncRequest struct {
Tasks []TaskState `json:"tasks"` Tasks []TaskState `json:"tasks"`
CanDelete bool `json:"canDelete"` // library.allow_delete is enabled CanDelete bool `json:"canDelete"` // library.allow_delete is enabled
DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk 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. // ControlAction represents a server-side control signal for a task.
@ -388,31 +342,11 @@ type LibraryDeleteRequest struct {
FilePath string `json:"filePath"` 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. // SyncResponse is returned by the server with all pending actions for the CLI.
type SyncResponse struct { type SyncResponse struct {
NewTasks []Task `json:"newTasks,omitempty"` NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"` Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"` StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
Watching bool `json:"watching"` Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"` Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"` Scan bool `json:"scan,omitempty"`

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

@ -2,7 +2,6 @@ package cmd
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -17,11 +16,9 @@ import (
"github.com/torrentclaw/unarr/internal/agent" "github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine" "github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/funnel"
"github.com/torrentclaw/unarr/internal/library" "github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/library/mediainfo" "github.com/torrentclaw/unarr/internal/mediaserver"
"github.com/torrentclaw/unarr/internal/usenet/download" "github.com/torrentclaw/unarr/internal/usenet/download"
"github.com/torrentclaw/unarr/internal/vpn"
) )
// newStartCmd creates the top-level `unarr start` command. // newStartCmd creates the top-level `unarr start` command.
@ -139,52 +136,22 @@ func runDaemonStart() error {
userAgent := "unarr/" + Version userAgent := "unarr/" + Version
// Probe HW accel + derive a sensible transcode resolution cap. The cap
// is what the web side uses to decide whether the user should pre-empt
// transcoding by downloading a smaller version (4K source on a software
// libx264-only host is the canonical case where pre-download wins).
//
// Use the full diagnostic (encoders + devices + ffmpeg version) instead
// of just the picked backend — the extra fields ride along in the
// register payload so the web "Diagnose transcoder" modal can show *why*
// libx264 was selected on a host with a GPU (e.g. brew's ffmpeg without
// --enable-nvenc). 10 s ceiling so a hung ffmpeg binary can't stall
// startup forever.
ffmpegResolved, _ := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer probeCancel() // guard against a panic inside DetectHWAccelDiagnostic
hwDiag := engine.DetectHWAccelDiagnostic(probeCtx, ffmpegResolved)
log.Println(hwDiag.LogLine())
hwAccelPick := hwDiag.Pick
maxTranscodeHeight := 1080
if hwAccelPick != engine.HWAccelNone {
maxTranscodeHeight = 2160
}
// Create daemon config // Create daemon config
daemonCfg := agent.DaemonConfig{ daemonCfg := agent.DaemonConfig{
AgentID: cfg.Agent.ID, AgentID: cfg.Agent.ID,
AgentName: cfg.Agent.Name, AgentName: cfg.Agent.Name,
Version: Version, Version: Version,
DownloadDir: cfg.Download.Dir, DownloadDir: cfg.Download.Dir,
StreamPort: cfg.Download.StreamPort, StreamPort: cfg.Download.StreamPort,
LanIP: engine.LanIP(), LanIP: engine.LanIP(),
TailscaleIP: engine.TailscaleIP(), TailscaleIP: engine.TailscaleIP(),
CanDelete: cfg.Library.AllowDelete, CanDelete: cfg.Library.AllowDelete,
ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath), ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath),
HWAccel: string(hwAccelPick),
MaxTranscodeHeight: maxTranscodeHeight,
FFmpegVersion: hwDiag.FFmpegVersion,
FFmpegPath: hwDiag.FFmpegPath,
HWEncoders: hwDiag.Encoders,
HWDevices: hwDiag.Devices,
AutoUpgrade: cfg.Daemon.AutoUpgradeEnabled(),
} }
// Create HTTP client with mirror failover so a `.com` block-out rolls // Create HTTP client — single communication channel
// over to `.to` / .onion without restarting the daemon. agentClient := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, userAgent)
agentClient := newAgentClientFromConfig(cfg, userAgent) log.Printf("Transport: HTTP sync → %s", cfg.Auth.APIURL)
log.Printf("Transport: HTTP sync → %s (mirrors: %d)", cfg.Auth.APIURL, len(cfg.Auth.Mirrors))
// Create daemon // Create daemon
d := agent.NewDaemon(daemonCfg, agentClient) d := agent.NewDaemon(daemonCfg, agentClient)
@ -213,68 +180,16 @@ func runDaemonStart() error {
reporter := engine.NewProgressReporter(agentClient, statusInterval) reporter := engine.NewProgressReporter(agentClient, statusInterval)
reporter.SetWatchingFunc(func() bool { return d.Watching.Load() }) reporter.SetWatchingFunc(func() bool { return d.Watching.Load() })
// Managed-VPN add-on: bring up the in-process WireGuard split-tunnel before
// the torrent client so peer + tracker traffic routes through it. Failure is
// non-fatal — log and download in the clear (better than refusing to run).
var vpnTunnel *vpn.Tunnel
if cfg.Download.VPN.ConfigFile != "" {
// Self-hosted / personal-VPN mode: read a local .conf directly.
raw, rerr := os.ReadFile(cfg.Download.VPN.ConfigFile)
if rerr != nil {
log.Printf("[vpn] could not read config_file %q (%v) — downloading in the clear", cfg.Download.VPN.ConfigFile, rerr)
} else if t, uerr := vpn.Up(string(raw)); uerr != nil {
log.Printf("[vpn] tunnel failed to start from config_file (%v) — downloading in the clear", uerr)
} else {
vpnTunnel = t
defer vpnTunnel.Close()
log.Printf("[vpn] managed VPN active (local config_file) — torrent traffic split-tunnelled through WireGuard")
}
} else if cfg.Download.VPN.Enabled {
apiURL := cfg.Auth.APIURL
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
fetchCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, false)
cancel()
var fe *vpn.FetchError
switch {
case ferr != nil && errors.As(ferr, &fe) && fe.Code == vpn.ErrSlotOnDevice:
log.Printf("[vpn] the single WireGuard slot is already held by another unarr agent — this one downloads in the clear. To protect this machine too, set up OpenVPN on it (1 agent uses WireGuard, the rest use OpenVPN — up to 10). See https://torrentclaw.com/vpn")
case ferr != nil:
log.Printf("[vpn] could not enable VPN (%v) — downloading in the clear", ferr)
default:
if t, uerr := vpn.Up(conf); uerr != nil {
log.Printf("[vpn] tunnel failed to start (%v) — downloading in the clear", uerr)
} else {
vpnTunnel = t
defer vpnTunnel.Close()
log.Printf("[vpn] managed VPN active — torrent traffic split-tunnelled through WireGuard")
}
}
}
// Record VPN split-tunnel state for `unarr vpn status`.
if vpnTunnel != nil {
mode := "managed"
if cfg.Download.VPN.ConfigFile != "" {
mode = "self-hosted"
}
d.SetVPNState(true, mode, vpnTunnel.Endpoint)
}
// Create torrent downloader // Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: cfg.Download.Dir, DataDir: cfg.Download.Dir,
PieceCompletionDir: config.DataDir(), // keep piece-completion DB off NFS/SMB mounts MetadataTimeout: metaTimeout,
MetadataTimeout: metaTimeout, StallTimeout: stallTimeout,
StallTimeout: stallTimeout, MaxTimeout: 0,
MaxTimeout: 0, MaxDownloadRate: maxDl,
MaxDownloadRate: maxDl, MaxUploadRate: maxUl,
MaxUploadRate: maxUl, ListenPort: cfg.Download.ListenPort,
ListenPort: cfg.Download.ListenPort, SeedEnabled: false,
SeedEnabled: false,
VPNTunnel: vpnTunnel,
}) })
if err != nil { if err != nil {
return fmt.Errorf("create torrent downloader: %w", err) return fmt.Errorf("create torrent downloader: %w", err)
@ -309,74 +224,11 @@ func runDaemonStart() error {
// Create persistent stream server // Create persistent stream server
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort) streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
// CORS extras = operator config + dynamic mirror list from /api/mirrors.
// Without the mirror merge, a user playing from `torrentclaw.to` (or any
// future mirror) hits the daemon, gets 200 + body, but no
// `Access-Control-Allow-Origin` → browser drops the response → player
// reports "404 todos los canales". Fetching /api/mirrors at startup
// future-proofs against mirror additions without a CLI rebuild.
corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...)
corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...)
streamSrv.SetCORSAllowedOrigins(corsExtras)
// Reap HLS tmpdirs left over from a previous daemon run before we start
// accepting new sessions. The in-memory registry doesn't survive a
// restart, so without this disk usage grows unbounded across restarts.
if err := engine.CleanupHLSOrphanDirs(); err != nil {
log.Printf("[hls] orphan tmpdir cleanup: %v", err)
}
// Persistent HLS segment cache — survives across sessions so re-plays
// of the same file at the same quality skip ffmpeg entirely. Off when
// hls_cache.enabled = false; size cap from hls_cache.size_gb; path from
// hls_cache.dir (defaults to ~/.cache/unarr/hls-cache).
var hlsCache *engine.HLSCache
if cfg.Download.HLSCache.Enabled {
cacheDir := cfg.Download.HLSCache.Dir
if cacheDir == "" {
if base, err := os.UserCacheDir(); err == nil {
cacheDir = filepath.Join(base, "unarr", "hls-cache")
} else {
cacheDir = filepath.Join(os.TempDir(), "unarr-hls-cache")
}
}
c, err := engine.NewHLSCache(cacheDir, cfg.Download.HLSCache.SizeGB)
if err != nil {
log.Printf("[hls_cache] init failed (%v) — falling back to per-session tmpdirs", err)
} else {
hlsCache = c
hlsCache.StartSweeper(ctx, time.Hour)
log.Printf("[hls_cache] enabled: dir=%s budget=%dGB", cacheDir, cfg.Download.HLSCache.SizeGB)
}
} else {
log.Printf("[hls_cache] disabled by config — every play re-encodes from scratch")
}
if err := streamSrv.Listen(ctx); err != nil { if err := streamSrv.Listen(ctx); err != nil {
return fmt.Errorf("start stream server: %w", err) return fmt.Errorf("start stream server: %w", err)
} }
d.UpdateStreamPort(streamSrv.Port()) d.UpdateStreamPort(streamSrv.Port())
// CloudFlare Quick Tunnel — needs the ACTUAL listening port (the
// configured port may have been busy and bumped). Spawning here ensures
// cloudflared --url points at the right socket. Failures degrade to
// Tailscale/LAN only; the supervisor keeps the tunnel up across CF's
// periodic rotation + transient cloudflared crashes.
if cfg.Download.Funnel.Enabled {
go superviseFunnel(ctx, d, streamSrv.Port())
}
// Warn at startup if transcode is enabled but ffmpeg/ffprobe are missing.
// HLS sessions get rejected at runtime (see daemon.go ~line 455), but
// surfacing it here gives the operator a chance to install ffmpeg before
// a user hits a confusing "rejected" line in the logs.
if cfg.Download.Transcode.Enabled {
if _, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err != nil {
log.Printf("[hls] transcode enabled but ffmpeg/ffprobe not found — install ffmpeg to use HLS")
} else if _, err := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath); err != nil {
log.Printf("[hls] transcode enabled but ffmpeg/ffprobe not found — install ffmpeg to use HLS")
}
}
// Wire sync client callbacks // Wire sync client callbacks
sc := d.SyncClient() sc := d.SyncClient()
sc.GetFreeSlots = manager.FreeSlots sc.GetFreeSlots = manager.FreeSlots
@ -386,10 +238,28 @@ func runDaemonStart() error {
// Trigger immediate sync when a download slot frees up // Trigger immediate sync when a download slot frees up
manager.OnTaskDone = func() { d.TriggerSync() } manager.OnTaskDone = func() { d.TriggerSync() }
// Trigger Plex/Jellyfin/Emby library refresh after a task finalises so
// the new file appears in the user's library within seconds (instead
// of waiting for the next periodic scan). No-op if no servers
// configured. Errors are logged inside Refresh and never propagate.
if len(cfg.MediaServers) > 0 {
manager.OnFinalized = func(task *engine.Task) {
if task == nil {
return
}
fp := task.SafeFilePath()
if fp == "" {
return
}
mediaserver.Refresh(cfg.MediaServers, fp)
}
}
// Wire: sync receives new tasks → submit to manager or handle stream // Wire: sync receives new tasks → submit to manager or handle stream
d.OnTasksClaimed = func(tasks []agent.Task) { d.OnTasksClaimed = func(tasks []agent.Task) {
for _, t := range tasks { for _, t := range tasks {
if t.Mode == "stream" { switch t.Mode {
case "stream":
if isStreamingTask(t.ID) { if isStreamingTask(t.ID) {
continue continue
} }
@ -400,7 +270,9 @@ func runDaemonStart() error {
streamRegistry.cancels[t.ID] = streamCancel streamRegistry.cancels[t.ID] = streamCancel
streamRegistry.mu.Unlock() streamRegistry.mu.Unlock()
go handleStreamTask(streamCtx, t, reporter, cfg, agentClient, streamSrv) go handleStreamTask(streamCtx, t, reporter, cfg, agentClient, streamSrv)
} else { case "strm-to-library":
go handleStrmToLibrary(ctx, t, cfg, agentClient)
default:
manager.Submit(ctx, t) manager.Submit(ctx, t)
} }
} }
@ -550,77 +422,6 @@ func runDaemonStart() error {
}() }()
} }
// Wire: sync receives HLS streaming session requests. Each session spawns
// one ffmpeg process and registers its HLS playlist with the StreamServer.
// Validate FilePath against allowed dirs to prevent path traversal abuse
// from a compromised server.
d.OnStreamSession = func(sess agent.StreamSession) {
if playerSessionRegistry.has(sess.SessionID) {
return // already running
}
filePath := sess.FilePath
if filePath == "" {
log.Printf("[hls %s] rejected: empty file path", agent.ShortID(sess.SessionID))
return
}
filePath = filepath.Clean(filePath)
if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
log.Printf("[hls %s] rejected: path outside allowed dirs: %s",
agent.ShortID(sess.SessionID), filePath)
return
}
// Resolve directory → first video file (matches StreamRequest behavior).
if info, err := os.Stat(filePath); err == nil && info.IsDir() {
found := engine.FindVideoFile(filePath)
if found == "" {
log.Printf("[hls %s] rejected: no video file in dir %s",
agent.ShortID(sess.SessionID), filePath)
return
}
filePath = found
}
tcRuntime := buildTranscodeRuntime(ctx, cfg)
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
return
}
hlsCtx, hlsCancel := context.WithCancel(ctx)
playerSessionRegistry.add(sess.SessionID, hlsCancel)
hlsCfg := engine.HLSSessionConfig{
SessionID: sess.SessionID,
SourcePath: filePath,
FileName: sess.FileName,
Quality: sess.Quality,
AudioIndex: sess.AudioIndex,
Transcode: tcRuntime,
Cache: hlsCache,
}
// StartHLSSession runs ffprobe (15 s cap, typical 0.31 s) before
// returning. Doing this synchronously inside the sync handler holds
// the next sync HTTP cycle until ffprobe is done, so any other
// pending actions (new tasks, deletes) wait too. Hand it off so
// the sync loop returns immediately — browser HEAD probes already
// have a 30 s retry budget that absorbs the gap until
// `streamSrv.HLS().Register` lands.
go func() {
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
if err != nil {
playerSessionRegistry.remove(sess.SessionID)
hlsCancel()
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
return
}
streamSrv.HLS().Register(hsess)
// Tell the server seg-0 is on disk as soon as it lands so the
// player's SSE subscription flips its "Preparando…" UI without
// waiting for the browser HEAD-probe loop to discover it
// independently. Cache-HIT sessions are ready immediately.
go watchSessionReady(hlsCtx, agentClient, hsess, sess.SessionID)
}()
}
// Periodic DHT node persistence (every 5 min) // Periodic DHT node persistence (every 5 min)
go func() { go func() {
ticker := time.NewTicker(5 * time.Minute) ticker := time.NewTicker(5 * time.Minute)
@ -635,25 +436,6 @@ func runDaemonStart() error {
} }
}() }()
// Periodic HLS session sweeper (every 5 min). Closes sessions whose last
// segment fetch was over 30 min ago — kills the orphan ffmpeg + removes
// the per-session tmpdir, so a tab that died mid-stream doesn't leak
// disk space until daemon shutdown.
go func() {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if n := streamSrv.HLS().SweepIdle(); n > 0 {
log.Printf("[hls] swept %d idle session(s)", n)
}
case <-ctx.Done():
return
}
}
}()
// Start auto-scan goroutine // Start auto-scan goroutine
scanPaths := daemonCfg.ScanPaths scanPaths := daemonCfg.ScanPaths
if len(scanPaths) > 0 && cfg.Library.AutoScan { if len(scanPaths) > 0 && cfg.Library.AutoScan {
@ -687,7 +469,6 @@ func runDaemonStart() error {
case sig := <-sigCh: case sig := <-sigCh:
fmt.Printf("\n Received %s, shutting down...\n", sig) fmt.Printf("\n Received %s, shutting down...\n", sig)
cancelStreamContexts() cancelStreamContexts()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background()) streamSrv.Shutdown(context.Background())
cancel() cancel()
@ -702,7 +483,6 @@ func runDaemonStart() error {
case err := <-errCh: case err := <-errCh:
cancelStreamContexts() cancelStreamContexts()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background()) streamSrv.Shutdown(context.Background())
cancel() cancel()
return err return err
@ -850,144 +630,3 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
} }
} }
} }
// superviseFunnel keeps a CloudFlare Quick Tunnel up across cloudflared
// crashes and CF's ~6h tunnel rotation. On a clean exit (cancellation) it
// returns; on a crash it clears the reported URL and respawns with an
// exponential backoff so we don't hammer cloudflared into a tight loop when
// it can't reach the CF edge.
func superviseFunnel(ctx context.Context, d *agent.Daemon, port int) {
backoff := 2 * time.Second
const maxBackoff = 5 * time.Minute
for ctx.Err() == nil {
t, err := funnel.Start(ctx, funnel.Config{Port: port})
if err != nil {
log.Printf("[funnel] could not start CloudFlare tunnel (%v) — retrying in %s", err, backoff)
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
backoff = min(backoff*2, maxBackoff)
continue
}
log.Printf("[funnel] cloudflared started, waiting for public URL...")
go func() {
url, werr := t.WaitURL(45 * time.Second)
if werr != nil {
log.Printf("[funnel] cloudflared did not emit a URL (%v)", werr)
return
}
log.Printf("[funnel] public URL: %s", url)
d.SetFunnelURL(url)
}()
// Block until cloudflared exits (CF rotation, crash, or shutdown).
exitErr := <-t.Done()
_ = t.Close()
d.SetFunnelURL("")
if ctx.Err() != nil {
return
}
if exitErr != nil {
log.Printf("[funnel] cloudflared exited: %v — restarting in %s", exitErr, backoff)
} else {
log.Printf("[funnel] cloudflared exited cleanly — restarting in %s", backoff)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
backoff = min(backoff*2, maxBackoff)
}
}
// mirrorCORSOrigins fetches /api/mirrors from the configured primary (+ extra
// mirror candidates + static IPFS fallback) and returns the discovered URLs as
// Origin strings. Best-effort: any failure logs a warning and returns an empty
// slice; the static defaultCORSAllowedOrigins in validate.go covers the known
// mirrors (.com / .to / built-in onion) so the daemon still accepts the
// official surfaces when this call fails.
//
// Bounded to a short timeout so a slow /api/mirrors response can't delay
// daemon startup — every second here is a second the user can't play.
func mirrorCORSOrigins(parent context.Context, cfg config.Config, userAgent string) []string {
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
candidates := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
resp, err := agent.FetchMirrorsWithFallback(ctx, candidates, userAgent)
if err != nil {
log.Printf("[cors] mirror discovery failed (%v) — using static allowlist only", err)
return nil
}
seen := make(map[string]struct{})
out := make([]string, 0, len(resp.Mirrors))
add := func(rawURL string) {
if rawURL == "" {
return
}
origin := strings.TrimRight(rawURL, "/")
if _, dup := seen[origin]; dup {
return
}
seen[origin] = struct{}{}
out = append(out, origin)
}
for _, m := range resp.Mirrors {
add(m.URL)
}
if resp.Tor != nil {
add(resp.Tor.URL)
}
if len(out) > 0 {
log.Printf("[cors] merged %d mirror origins from /api/mirrors", len(out))
}
return out
}
// watchSessionReady polls HLSSession.ReadyCount until the first segment +
// init.mp4 are on disk, then POSTs /api/internal/agent/session-ready so
// the web side flips streaming_session.ready_at — which its SSE endpoint
// pushes to subscribed players. Cache-HIT sessions are ready the moment
// StartHLSSession returns and POST immediately.
//
// Bounded by a 60 s deadline so a permanently stuck encoder doesn't keep
// a goroutine alive forever; if seg-0 never lands the player falls back
// to its existing HEAD-probe retry path anyway.
func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine.HLSSession, sessionID string) {
deadline := time.Now().Add(60 * time.Second)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
// Session torn down through a path that didn't cancel ctx (registry
// replace, idle sweep, internal kill). Bail before polling further —
// without this check the watcher could keep alive for up to 60 s on
// a dead HLSSession that's never going to become ready.
if hsess.IsClosed() {
return
}
// Cache HIT or seg-0 ready → notify + done.
if hsess.FromCache() || hsess.ReadyCount() >= 1 {
// Parent ctx so a session cancel mid-POST (user closed tab,
// daemon shutdown) tears down the in-flight webhook instead of
// blocking the goroutine for up to 10 s on a now-orphan call.
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
if err := client.MarkSessionReady(rctx, sessionID); err != nil {
log.Printf("[hls %s] mark-ready failed: %v", agent.ShortID(sessionID), err)
}
cancel()
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if time.Now().After(deadline) {
log.Printf("[hls %s] mark-ready: timeout waiting for seg-0", agent.ShortID(sessionID))
return
}
}
}

View file

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

View file

@ -119,10 +119,11 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
return fmt.Errorf("create downloader: %w", err) return fmt.Errorf("create downloader: %w", err)
} }
// Local-only reporter: one-shot downloads have no server-side task, so a nil // Create a dummy reporter (no API reporting for one-shot)
// client keeps terminal progress working without spamming the status API reporter := engine.NewProgressReporter(
// (which 400s the synthetic "oneshot-" id). deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
reporter := engine.NewProgressReporter(nil, 5*time.Second) 5*time.Second,
)
debridDl := deps.newDebridDl() debridDl := deps.newDebridDl()

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

View file

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

View file

@ -296,6 +296,34 @@ func runInit(apiURLOverride string) error {
} }
} }
// ── Plex / Jellyfin / Emby refresh hook ────────────────────────
// Offer to wire library refreshes if a media server was detected and
// none are configured yet. Skipping here is fine — the user can run
// `unarr mediaserver setup` later.
if len(detected.Servers) > 0 && len(cfg.MediaServers) == 0 {
fmt.Println()
var configureMS bool
err = huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Auto-refresh %s on every download?", detected.Servers[0].Name)).
Description("New downloads appear on Roku/Apple TV/etc. within seconds, instead of waiting for the next periodic library scan").
Affirmative("Yes, configure").
Negative("Skip (do it later with 'unarr mediaserver setup')").
Value(&configureMS),
),
).Run()
if err == nil && configureMS {
fmt.Println()
if err := runMediaserverSetup(); err != nil {
color.New(color.FgYellow).Printf(" Media server setup failed: %s\n", err)
} else {
// runMediaserverSetup already saved + updated appCfg.
cfg = appCfg
}
}
}
// ── Debrid auto-detection from *arr ───────────────────────────── // ── Debrid auto-detection from *arr ─────────────────────────────
if resp.User.IsPro { if resp.User.IsPro {

335
internal/cmd/mediaserver.go Normal file
View file

@ -0,0 +1,335 @@
package cmd
import (
"errors"
"fmt"
"strings"
"github.com/charmbracelet/huh"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/mediaserver"
)
func newMediaserverCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "mediaserver",
Aliases: []string{"plex", "jellyfin"},
Short: "Configure Plex / Jellyfin / Emby auto-refresh",
Long: `Manage the list of media servers that unarr should refresh after a
download finishes.
When configured, unarr triggers a partial library refresh on each server
right after a download is verified and organised, so the new file shows
up in your Plex / Jellyfin / Emby (and therefore on Roku, Apple TV, Fire
TV, etc.) within seconds instead of waiting for the next periodic scan.`,
}
cmd.AddCommand(
newMediaserverSetupCmd(),
newMediaserverListCmd(),
newMediaserverRemoveCmd(),
newMediaserverTestCmd(),
)
return cmd
}
func newMediaserverSetupCmd() *cobra.Command {
return &cobra.Command{
Use: "setup",
Short: "Interactive wizard to add a Plex / Jellyfin / Emby server",
RunE: func(cmd *cobra.Command, args []string) error {
return runMediaserverSetup()
},
}
}
func newMediaserverListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List configured media servers",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadConfig()
if len(cfg.MediaServers) == 0 {
fmt.Println("No media servers configured. Run 'unarr mediaserver setup' to add one.")
return nil
}
for i, s := range cfg.MediaServers {
fmt.Printf("%d. %s @ %s\n", i+1, strings.ToUpper(s.Kind), s.URL)
if len(s.Sections) > 0 {
fmt.Printf(" Sections: %v\n", s.Sections)
}
}
return nil
},
}
}
func newMediaserverRemoveCmd() *cobra.Command {
return &cobra.Command{
Use: "remove",
Short: "Remove a configured media server",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadConfig()
if len(cfg.MediaServers) == 0 {
fmt.Println("No media servers configured.")
return nil
}
var options []huh.Option[int]
for i, s := range cfg.MediaServers {
label := fmt.Sprintf("%s @ %s", strings.ToUpper(s.Kind), s.URL)
options = append(options, huh.NewOption(label, i))
}
var idx int
err := huh.NewForm(
huh.NewGroup(
huh.NewSelect[int]().
Title("Which server to remove?").
Options(options...).
Value(&idx),
),
).Run()
if err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
cfg.MediaServers = append(cfg.MediaServers[:idx], cfg.MediaServers[idx+1:]...)
if err := config.Save(cfg, cfgFile); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg
color.New(color.FgGreen).Println(" ✓ Removed.")
return nil
},
}
}
func newMediaserverTestCmd() *cobra.Command {
return &cobra.Command{
Use: "test",
Short: "Trigger a refresh on each configured server (sanity check)",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadConfig()
if len(cfg.MediaServers) == 0 {
fmt.Println("No media servers configured.")
return nil
}
for _, s := range cfg.MediaServers {
fmt.Printf("Refreshing %s @ %s ... ", s.Kind, s.URL)
mediaserver.Refresh([]mediaserver.ServerConfig{s}, "")
}
// Refresh fans out goroutines; give them time to log results.
fmt.Println("dispatched (errors, if any, are logged).")
return nil
},
}
}
// runMediaserverSetup walks the user through adding a single media server.
// Auto-detects local Plex/Jellyfin/Emby via port scan and prefills as much
// as possible.
func runMediaserverSetup() error {
if !isTerminal() {
return fmt.Errorf("interactive mode requires a terminal")
}
bold := color.New(color.Bold)
cyan := color.New(color.FgCyan)
dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
cfg := loadConfig()
fmt.Println()
bold.Println(" Add a media server")
fmt.Println()
dim.Println(" unarr will hit the server's refresh API after each download,")
dim.Println(" so new files appear in your library within seconds.")
fmt.Println()
detected := mediaserver.Detect()
// Pick kind
var kind string
kindOpts := []huh.Option[string]{
huh.NewOption("Plex", "plex"),
huh.NewOption("Jellyfin", "jellyfin"),
huh.NewOption("Emby", "emby"),
}
// Default selection: first detected server's kind, lower-cased.
if len(detected.Servers) > 0 {
kind = strings.ToLower(detected.Servers[0].Name)
} else {
kind = "plex"
}
if err := huh.NewForm(huh.NewGroup(
huh.NewSelect[string]().
Title("Server type").
Options(kindOpts...).
Value(&kind),
)).Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
// Prefill URL from detection if available
url := ""
for _, s := range detected.Servers {
if strings.EqualFold(s.Name, kind) {
url = s.URL
break
}
}
if url == "" {
url = defaultURLFor(kind)
}
if err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title("Server URL").
Description("Reachable from this machine — e.g. http://localhost:32400").
Value(&url).
Validate(func(s string) error {
s = strings.TrimSpace(s)
if s == "" {
return fmt.Errorf("URL is required")
}
if !strings.HasPrefix(s, "http://") && !strings.HasPrefix(s, "https://") {
return fmt.Errorf("must start with http:// or https://")
}
return nil
}),
)).Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
url = strings.TrimRight(strings.TrimSpace(url), "/")
// Token entry
token := ""
if kind == "plex" {
// Try local Preferences.xml first (works when Plex runs on same host).
if t := mediaserver.LocalPlexToken(); t != "" {
cyan.Println(" ✓ Found Plex token in local Preferences.xml")
fmt.Println()
useLocal := true
_ = huh.NewForm(huh.NewGroup(
huh.NewConfirm().
Title("Use the auto-detected token?").
Affirmative("Yes").
Negative("No, paste a different one").
Value(&useLocal),
)).Run()
if useLocal {
token = t
}
}
}
if token == "" {
title := "API key"
desc := ""
switch kind {
case "plex":
title = "Plex token"
desc = "Get it via Plex web UI → any item → ⋯ → Get Info → View XML → copy ?X-Plex-Token=... from URL"
case "jellyfin":
title = "Jellyfin API key"
desc = "Dashboard → Advanced → API Keys → New API Key"
case "emby":
title = "Emby API key"
desc = "Server Dashboard → API Keys → New Application"
}
if err := huh.NewForm(huh.NewGroup(
huh.NewInput().
Title(title).
Description(desc).
Value(&token).
Validate(func(s string) error {
if strings.TrimSpace(s) == "" {
return fmt.Errorf("token is required")
}
return nil
}),
)).Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return nil
}
return err
}
}
token = strings.TrimSpace(token)
// Save
newServer := mediaserver.ServerConfig{
Kind: kind,
URL: url,
Token: token,
}
// Replace if same kind+URL already present, else append
replaced := false
for i, existing := range cfg.MediaServers {
if strings.EqualFold(existing.Kind, kind) && existing.URL == url {
cfg.MediaServers[i] = newServer
replaced = true
break
}
}
if !replaced {
cfg.MediaServers = append(cfg.MediaServers, newServer)
}
if err := config.Save(cfg, cfgFile); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg
fmt.Println()
if replaced {
green.Printf(" ✓ Updated %s @ %s\n", strings.ToUpper(kind), url)
} else {
green.Printf(" ✓ Added %s @ %s\n", strings.ToUpper(kind), url)
}
fmt.Println()
// Sanity test
doTest := true
_ = huh.NewForm(huh.NewGroup(
huh.NewConfirm().
Title("Trigger a test refresh now?").
Affirmative("Yes").
Negative("Skip").
Value(&doTest),
)).Run()
if doTest {
mediaserver.Refresh([]mediaserver.ServerConfig{newServer}, "")
fmt.Println(" Refresh dispatched. If it failed, the error is in the logs.")
}
return nil
}
func defaultURLFor(kind string) string {
switch strings.ToLower(kind) {
case "plex":
return "http://localhost:32400"
case "jellyfin":
return "http://localhost:8096"
case "emby":
return "http://localhost:8920"
}
return ""
}

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

View file

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

View file

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

View file

@ -3,17 +3,19 @@ package cmd
import ( import (
"context" "context"
"fmt" "fmt"
"os"
"os/exec"
"runtime"
"strings" "strings"
"syscall"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/upgrade" "github.com/torrentclaw/unarr/internal/upgrade"
) )
func newSelfUpdateCmd() *cobra.Command { func newSelfUpdateCmd() *cobra.Command {
var force bool var force bool
var allowUnsigned bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "self-update", Use: "self-update",
@ -21,35 +23,29 @@ func newSelfUpdateCmd() *cobra.Command {
Long: `Download and install the latest version of unarr. Long: `Download and install the latest version of unarr.
Checks GitHub for the latest release, verifies the checksum, and Checks GitHub for the latest release, verifies the checksum, and
replaces the current binary. A backup is kept at <binary>.backup. replaces the current binary. A backup is kept at <binary>.backup.`,
If the daemon is running, it is automatically restarted so the new
version is loaded into memory (otherwise heartbeat would keep
reporting the old version until a manual restart).`,
Example: ` unarr self-update Example: ` unarr self-update
unarr self-update --force unarr self-update --force`,
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force, allowUnsigned) return runSelfUpdate(force)
}, },
} }
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date") cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
return cmd return cmd
} }
func runSelfUpdate(force, allowUnsigned bool) error { func runSelfUpdate(force bool) error {
bold := color.New(color.Bold) bold := color.New(color.Bold)
green := color.New(color.FgGreen) green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow) yellow := color.New(color.FgYellow)
red := color.New(color.FgRed)
fmt.Println() fmt.Println()
bold.Println(" unarr self-update") bold.Println(" unarr self-update")
fmt.Println() fmt.Println()
// Check latest version
fmt.Print(" Checking latest version... ") fmt.Print(" Checking latest version... ")
ctx := context.Background() ctx := context.Background()
latest, err := upgrade.CheckLatest(ctx) latest, err := upgrade.CheckLatest(ctx)
@ -77,7 +73,6 @@ func runSelfUpdate(force, allowUnsigned bool) error {
upgrader := &upgrade.Upgrader{ upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean, CurrentVersion: currentClean,
AllowUnsigned: allowUnsigned,
OnProgress: func(msg string) { OnProgress: func(msg string) {
fmt.Printf(" %s\n", msg) fmt.Printf(" %s\n", msg)
}, },
@ -94,25 +89,37 @@ func runSelfUpdate(force, allowUnsigned bool) error {
if result.BackupPath != "" { if result.BackupPath != "" {
fmt.Printf(" Backup: %s\n", result.BackupPath) fmt.Printf(" Backup: %s\n", result.BackupPath)
} }
fmt.Println()
// Auto-restart daemon if it is running, otherwise the live process keeps // If running as daemon, re-exec to restart with new binary
// serving the old version (heartbeat reports old version → web gates // For interactive use, just suggest restarting
// features against the wrong version). if isRunningAsDaemon() {
if state := agent.ReadState(); state != nil && isDaemonAlive(state) { fmt.Println(" Restarting daemon with new version...")
fmt.Println() binPath, err := os.Executable()
fmt.Printf(" → Daemon running (PID %d), restarting to load new version...\n", state.PID) if err != nil {
if err := runDaemonSvcRestart(); err != nil { return fmt.Errorf("could not determine executable path: %w", err)
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
} }
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 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

@ -2,7 +2,6 @@ package cmd
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"runtime" "runtime"
"strings" "strings"
@ -59,7 +58,7 @@ func runStatus() error {
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel() defer cancel()
ac := newAgentClientFromConfig(cfg, "unarr/"+Version) ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
resp, err := ac.Register(ctx, agent.RegisterRequest{ resp, err := ac.Register(ctx, agent.RegisterRequest{
AgentID: cfg.Agent.ID, AgentID: cfg.Agent.ID,
Name: cfg.Agent.Name, Name: cfg.Agent.Name,
@ -75,17 +74,7 @@ func runStatus() error {
cyan.Println(" Account") cyan.Println(" Account")
ar := <-accountCh ar := <-accountCh
if ar.err != nil { if ar.err != nil {
var httpErr *agent.HTTPError dim.Println(" Could not fetch account info")
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 { } else {
fmt.Printf(" User: %s\n", ar.user.Name) fmt.Printf(" User: %s\n", ar.user.Name)
fmt.Printf(" Email: %s\n", ar.user.Email) fmt.Printf(" Email: %s\n", ar.user.Email)

View file

@ -0,0 +1,70 @@
package cmd
import (
"context"
"log"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/mediaserver"
)
// handleStrmToLibrary processes a Mode="strm-to-library" task by writing a
// one-line .strm file to the user's media library and triggering a
// Plex/Jellyfin/Emby refresh. No actual download happens; the .strm points
// at the cloud-resolved debrid HTTPS URL, and the media server streams from
// there when the user presses play.
//
// Reports completion (or failure) back to the cloud via the agent client.
func handleStrmToLibrary(ctx context.Context, t agent.Task, cfg config.Config, agentClient *agent.Client) {
short := agent.ShortID(t.ID)
if t.DirectURL == "" {
log.Printf("[%s] strm-to-library: missing directUrl from server", short)
reportStrmFailure(ctx, agentClient, t.ID, "missing directUrl")
return
}
organizeCfg := engine.OrganizeConfig{
Enabled: cfg.Organize.Enabled,
MoviesDir: cfg.Organize.MoviesDir,
TVShowsDir: cfg.Organize.TVShowsDir,
OutputDir: cfg.Download.Dir,
}
finalPath, err := engine.WriteStrm(t, organizeCfg)
if err != nil {
log.Printf("[%s] strm-to-library write failed: %v", short, err)
reportStrmFailure(ctx, agentClient, t.ID, err.Error())
return
}
log.Printf("[%s] strm-to-library wrote %s", short, finalPath)
// Trigger media-server refresh if any are configured. Errors are logged
// inside Refresh and never propagate — the .strm is on disk, so the
// next periodic scan would pick it up regardless.
if len(cfg.MediaServers) > 0 {
mediaserver.Refresh(cfg.MediaServers, finalPath)
}
if _, reportErr := agentClient.ReportStatus(ctx, agent.StatusUpdate{
TaskID: t.ID,
Status: "completed",
Progress: 100,
FilePath: finalPath,
}); reportErr != nil {
log.Printf("[%s] strm-to-library: status report failed: %v", short, reportErr)
}
}
func reportStrmFailure(ctx context.Context, agentClient *agent.Client, taskID, msg string) {
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
TaskID: taskID,
Status: "failed",
ErrorMessage: msg,
}); err != nil {
log.Printf("[%s] strm failure report failed: %v", agent.ShortID(taskID), err)
}
}

View file

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

View file

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

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

@ -9,28 +9,25 @@ import (
"strings" "strings"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/torrentclaw/unarr/internal/mediaserver"
) )
// Config holds all persistent CLI configuration. // Config holds all persistent CLI configuration.
type Config struct { type Config struct {
Auth AuthConfig `toml:"auth"` Auth AuthConfig `toml:"auth"`
Agent AgentConfig `toml:"agent"` Agent AgentConfig `toml:"agent"`
Download DownloadConfig `toml:"downloads"` Download DownloadConfig `toml:"downloads"`
Organize OrganizeConfig `toml:"organize"` Organize OrganizeConfig `toml:"organize"`
Daemon DaemonConfig `toml:"daemon"` Daemon DaemonConfig `toml:"daemon"`
Notifications NotificationsConfig `toml:"notifications"` Notifications NotificationsConfig `toml:"notifications"`
General GeneralConfig `toml:"general"` General GeneralConfig `toml:"general"`
Library LibraryConfig `toml:"library"` Library LibraryConfig `toml:"library"`
MediaServers []mediaserver.ServerConfig `toml:"mediaserver"`
} }
type AuthConfig struct { type AuthConfig struct {
APIKey string `toml:"api_key"` APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"` APIURL string `toml:"api_url"`
// Mirrors lists alternate base URLs the agent will fall back to when the
// primary api_url is unreachable. Ordered by preference. Refreshed at
// runtime by `unarr mirrors update` against /api/v1/mirrors so a long-
// running agent survives a primary takedown without a new release.
Mirrors []string `toml:"mirrors"`
} }
type AgentConfig struct { type AgentConfig struct {
@ -39,88 +36,16 @@ type AgentConfig struct {
} }
type DownloadConfig struct { type DownloadConfig struct {
Dir string `toml:"dir"` Dir string `toml:"dir"`
PreferredMethod string `toml:"preferred_method"` PreferredMethod string `toml:"preferred_method"`
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"` MaxConcurrent int `toml:"max_concurrent"`
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
Transcode TranscodeConfig `toml:"transcode"`
HLSCache HLSCacheConfig `toml:"hls_cache"`
VPN VPNConfig `toml:"vpn"`
Funnel FunnelConfig `toml:"funnel"`
}
// HLSCacheConfig controls the persistent HLS segment cache. A completed encode
// is kept on disk so a second play of the same file at the same quality skips
// ffmpeg entirely. Old entries are evicted (LRU) once the cache exceeds the
// size budget. Enabled by default — disable to save disk space at the cost of
// re-encoding every play.
type HLSCacheConfig struct {
Enabled bool `toml:"enabled"` // default: true
SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1
Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache
}
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
// daemon's HLS server over a public HTTPS hostname (https://<random>.try
// cloudflare.com). Enabling it lets the web player on torrentclaw.com play
// from this daemon across any network without Tailscale or a public IP —
// the cost is that bytes proxy through CloudFlare's network. Off by default.
type FunnelConfig struct {
Enabled bool `toml:"enabled"`
}
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
// routes only the torrent client's peer/tracker traffic through an in-process
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
// add-on on the account; otherwise the daemon logs and downloads in the clear.
type VPNConfig struct {
Enabled bool `toml:"enabled"`
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
// of fetching one from the web API. For self-hosted / personal-VPN testing:
// point it at a peer .conf from your own WireGuard server and the torrent
// client split-tunnels through it with no web/provider plumbing.
ConfigFile string `toml:"config_file"`
}
// TranscodeConfig controls real-time transcoding for the in-browser player
// when source codecs aren't browser-decodable (HEVC, AV1, AC3, DTS, etc.).
// Disabled by default; enabling requires ffmpeg + ffprobe on PATH (or
// explicit paths via the library config).
type TranscodeConfig struct {
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
// Preset is the encoder speed/quality dial. Only used on software encode
// (libx264) — HW backends (NVENC/QSV/VAAPI/VideoToolbox) use vendor
// presets that don't share libx264's vocabulary and would be rejected
// by ffmpeg if passed here.
//
// Empty (default) → engine picks "superfast" — latency-biased, ~3 s
// first-play on 1080p source on a modern x86 CPU. Marginal quality loss
// at 5-25 Mbps target bitrates.
//
// For better quality at slower first-play (1-2 s slower per seg):
// "veryfast" — previous default; balanced
// "faster" — slight quality bump
// "fast" — meaningful quality bump
// "medium" — libx264 stock default; CPU-bound on 4K
// "slow" / "slower" / "veryslow" — only for batch encodes, not real-time HLS
//
// Or faster:
// "ultrafast" — lowest quality, fastest encode
Preset string `toml:"preset"`
VideoBitrate string `toml:"video_bitrate"` // e.g. "5M"
AudioBitrate string `toml:"audio_bitrate"` // e.g. "192k"
MaxHeight int `toml:"max_height"` // optional downscale cap (e.g. 720)
MaxConcurrent int `toml:"max_concurrent"` // safety cap on simultaneous transcoder processes
} }
type OrganizeConfig struct { type OrganizeConfig struct {
@ -131,27 +56,8 @@ type OrganizeConfig struct {
type DaemonConfig struct { type DaemonConfig struct {
StatusInterval string `toml:"status_interval"` StatusInterval string `toml:"status_interval"`
// AutoUpgrade gates the daemon's response to a server-flagged upgrade
// (set via the "Force update" button on the web). When true the daemon
// downloads + replaces the binary in-place and exits so the service
// supervisor respawns on the new version. When false the daemon only
// logs "new version available" and the operator must run `unarr update`
// manually. Default: true. Available since unarr 0.9.6.
AutoUpgrade *bool `toml:"auto_upgrade"`
} }
// AutoUpgradeEnabled returns the resolved AutoUpgrade flag — defaults to true
// when the user has not set it explicitly. Pointer-vs-bool because Go's
// zero-value bool would collapse "unset" and "false" together.
func (d DaemonConfig) AutoUpgradeEnabled() bool {
if d.AutoUpgrade == nil {
return true
}
return *d.AutoUpgrade
}
func boolPtr(v bool) *bool { return &v }
type NotificationsConfig struct { type NotificationsConfig struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
} }
@ -166,66 +72,27 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8) Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true) AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h") ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk 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 // Default returns a Config with sensible defaults.
// installs (no config file yet) and as the baseline for Load — fields not
// present in the user's TOML keep their Default() value.
func Default() Config { func Default() Config {
return Config{ return Config{
Auth: AuthConfig{ Auth: AuthConfig{
APIURL: "https://torrentclaw.com", APIURL: "https://torrentclaw.com",
// Default mirror list. Kept in sync with src/lib/mirrors-config.ts
// on the server. Users can override with `unarr mirrors update`,
// which pulls the live list from /api/v1/mirrors.
Mirrors: []string{
"https://torrentclaw.to",
},
}, },
Download: DownloadConfig{ Download: DownloadConfig{
PreferredMethod: "auto", PreferredMethod: "auto",
MaxConcurrent: 3, MaxConcurrent: 3,
StreamPort: 11818, StreamPort: 11818,
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
// Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters.
Preset: "",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
Funnel: FunnelConfig{
// On by default so headless installs (NAS / Docker) get cross-network
// HTTPS playback without anyone having to terminal in. Users who
// don't want bytes proxied through CloudFlare can opt out with
// `unarr funnel off` (sets enabled=false in the TOML).
Enabled: true,
},
HLSCache: HLSCacheConfig{
// On by default — second play of a recently watched file at the
// same quality skips ffmpeg (instant start, near-zero CPU).
// Users can opt out (hls_cache.enabled=false) or shrink the
// budget (hls_cache.size_gb) when disk is tight.
Enabled: true,
SizeGB: 5,
},
},
Daemon: DaemonConfig{
// Pointer-to-true so Default() round-trips through TOML marshal
// as `auto_upgrade = true` instead of an omitted key — keeps the
// freshly-written config aligned with what README documents.
AutoUpgrade: boolPtr(true),
}, },
Organize: OrganizeConfig{ Organize: OrganizeConfig{
Enabled: true, Enabled: true,
}, },
Daemon: DaemonConfig{},
Notifications: NotificationsConfig{ Notifications: NotificationsConfig{
Enabled: true, Enabled: true,
}, },
@ -259,67 +126,28 @@ func Load(path string) (Config, error) {
return cfg, fmt.Errorf("read config: %w", err) return cfg, fmt.Errorf("read config: %w", err)
} }
meta, err := toml.Decode(string(data), &cfg) if err := toml.Unmarshal(data, &cfg); err != nil {
if err != nil {
return cfg, fmt.Errorf("parse config: %w", err) return cfg, fmt.Errorf("parse config: %w", err)
} }
applyDefaults(&cfg, meta) // Re-apply defaults for zero values that should have defaults
return cfg, nil if cfg.Auth.APIURL == "" {
}
// applyDefaults fills in sensible defaults for keys that the user did not
// define in the TOML file. We use MetaData (rather than zero-value checks) so
// that explicitly setting a field to its zero value (e.g. `enabled = false`)
// is respected — only truly missing keys get defaulted. This lets a fresh
// install work out of the box for streaming without forcing every user to
// edit the TOML, while still letting power users disable features.
func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("auth", "api_url") {
cfg.Auth.APIURL = "https://torrentclaw.com" cfg.Auth.APIURL = "https://torrentclaw.com"
} }
if !meta.IsDefined("auth", "mirrors") { if cfg.Download.PreferredMethod == "" {
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
}
if !meta.IsDefined("downloads", "preferred_method") {
cfg.Download.PreferredMethod = "auto" cfg.Download.PreferredMethod = "auto"
} }
if !meta.IsDefined("downloads", "max_concurrent") { if cfg.Download.MaxConcurrent == 0 {
cfg.Download.MaxConcurrent = 3 cfg.Download.MaxConcurrent = 3
} }
if !meta.IsDefined("downloads", "stream_port") { if cfg.General.Country == "" {
cfg.Download.StreamPort = 11818
}
if !meta.IsDefined("general", "country") {
cfg.General.Country = "US" cfg.General.Country = "US"
} }
if cfg.Download.StreamPort == 0 {
cfg.Download.StreamPort = 11818
}
if !meta.IsDefined("downloads", "transcode", "enabled") { return cfg, nil
cfg.Download.Transcode.Enabled = true
}
if !meta.IsDefined("downloads", "transcode", "hw_accel") {
cfg.Download.Transcode.HWAccel = "auto"
}
if !meta.IsDefined("downloads", "transcode", "preset") {
// Empty = let engine.ResolveEncoderProfile pick the latency-biased
// default ("superfast" on libx264). Users wanting better quality at
// slower first-play can override to "veryfast" / "fast" / "medium" in
// config.toml. Ignored when hw_accel picks NVENC/QSV/VAAPI/VideoToolbox
// (those have built-in vendor presets).
cfg.Download.Transcode.Preset = ""
}
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
cfg.Download.Transcode.AudioBitrate = "192k"
}
if !meta.IsDefined("downloads", "transcode", "max_concurrent") {
cfg.Download.Transcode.MaxConcurrent = 2
}
// NOTE: Funnel default-ON only applies to fresh installs (no config file →
// Default() returns Funnel.Enabled=true straight off). When an existing
// config file lacks `[downloads.funnel]` entirely we intentionally do NOT
// flip it on here — that would silently route an upgraded operator's
// traffic through CloudFlare without their consent. They opt in with
// `unarr funnel on` whenever they're ready.
} }
// Save writes config to the default or specified path using atomic write. // Save writes config to the default or specified path using atomic write.

View file

@ -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) { func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml") path := filepath.Join(tmp, "config.toml")

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

@ -33,6 +33,12 @@ type Manager struct {
// Used by the daemon to trigger an immediate sync. // Used by the daemon to trigger an immediate sync.
OnTaskDone func() OnTaskDone func()
// OnFinalized is called after a task successfully transitions to
// completed (after verify + organize + optional upgrade replace).
// Used by the daemon to trigger media-server library refreshes.
// Not invoked on failure.
OnFinalized func(task *Task)
// recentlyFinished holds tasks that completed/failed since the last sync read. // 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. // The sync goroutine reads and clears this to include final states in the next sync.
recentMu sync.Mutex recentMu sync.Mutex
@ -444,6 +450,9 @@ func (m *Manager) finalize(ctx context.Context, task *Task, result *Result) {
if m.cfg.Notifications { if m.cfg.Notifications {
desktopNotify("Download complete", task.Title) desktopNotify("Download complete", task.Title)
} }
if m.OnFinalized != nil {
m.OnFinalized(task)
}
m.recordFinished(task.ToStatusUpdate()) m.recordFinished(task.ToStatusUpdate())
m.reporter.ReportFinal(ctx, task) m.reporter.ReportFinal(ctx, task)
} }

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

@ -45,19 +45,10 @@ type ProgressReporter struct {
lastCheckAt time.Time // last time we reported for control-signal polling lastCheckAt time.Time // last time we reported for control-signal polling
} }
// NewProgressReporter creates a reporter that flushes every interval. A nil // NewProgressReporter creates a reporter that flushes every interval.
// client yields a local-only reporter that tracks progress for terminal output
// but never calls the API — used by one-shot `unarr download`, which has no
// server-side task to report against (its synthetic "oneshot-" id is not a UUID
// and the /api/internal/agent/status endpoint 400s it). Passing the typed nil
// straight into the interface field would make it non-nil, so guard explicitly.
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter { func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
var rep StatusReporter
if ac != nil {
rep = ac
}
return &ProgressReporter{ return &ProgressReporter{
reporter: rep, reporter: ac,
interval: interval, interval: interval,
latest: make(map[string]*Task), latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus), lastReported: make(map[string]TaskStatus),
@ -117,9 +108,6 @@ func (r *ProgressReporter) Run(ctx context.Context) error {
} }
func (r *ProgressReporter) flush(ctx context.Context) { func (r *ProgressReporter) flush(ctx context.Context) {
if r.reporter == nil {
return // local-only reporter (one-shot): nothing to send
}
r.mu.Lock() r.mu.Lock()
tasks := make([]*Task, 0, len(r.latest)) tasks := make([]*Task, 0, len(r.latest))
for _, t := range r.latest { for _, t := range r.latest {
@ -251,10 +239,6 @@ func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse
// ReportFinal sends a final status update for a completed/failed task. // ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) { func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
if r.reporter == nil {
r.Untrack(task.ID)
return // local-only reporter (one-shot)
}
update := task.ToStatusUpdate() update := task.ToStatusUpdate()
if _, err := r.reporter.ReportStatus(ctx, update); err != nil { if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err) log.Printf("[%s] final report failed: %v", task.ID[:8], err)

View file

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

View file

@ -50,20 +50,7 @@ type StreamServer struct {
url string // best single URL (backward compat) url string // best single URL (backward compat)
urls StreamURLs // all available URLs by network type urls StreamURLs // all available URLs by network type
upnpMapping *UPnPMapping upnpMapping *UPnPMapping
// enableUPnP gates whether Listen() asks the gateway to publish the disableUPnP bool
// stream port to the WAN. UPnP is opt-in (false by default) because
// /stream and /hls have no auth — exposing them on the public internet
// would let any scanner enumerate active downloads. LAN and Tailscale
// access keep working without UPnP.
enableUPnP bool
// corsExtraOrigins are operator-configured origins added to the default
// allowlist defined in validate.go. Set before Listen().
corsExtraOrigins []string
// corsAllowlist is computed at Listen() time and treated as read-only
// thereafter so per-request reads need no locking.
corsAllowlist map[string]struct{}
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
lastActivity atomic.Int64 lastActivity atomic.Int64
maxByteOffset atomic.Int64 // highest sequential read position (main playback connection) maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
@ -76,78 +63,16 @@ type StreamServer struct {
// NewStreamServer creates a stream server bound to the given port. // NewStreamServer creates a stream server bound to the given port.
// Call Listen() to start accepting connections, then SetFile() to serve content. // Call Listen() to start accepting connections, then SetFile() to serve content.
//
// UPnP is opt-in: call SetUPnPEnabled(true) before Listen() to publish the
// stream port on the WAN. Without it, only LAN and Tailscale clients can
// reach the server. This matches the security default — /stream and /hls
// have no auth, so exposing them to the public internet is something the
// operator must explicitly request.
func NewStreamServer(port int) *StreamServer { func NewStreamServer(port int) *StreamServer {
return &StreamServer{port: port, hls: NewHLSSessionRegistry()} return &StreamServer{port: port}
} }
// SetUPnPEnabled toggles WAN publishing of the stream port. Call before
// Listen(); changes after Listen() are ignored for the active server.
func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
ss.enableUPnP = enabled
}
// SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
// ports) is always merged in. Call before Listen().
func (ss *StreamServer) SetCORSAllowedOrigins(origins []string) {
ss.corsExtraOrigins = origins
}
// writeCORSHeaders writes the per-origin CORS response headers when the
// request carries an Origin header that matches the allowlist. Returns true
// if the handler must short-circuit (preflight OPTIONS). Media-tag requests
// (no Origin header) bypass this entirely.
//
// `Vary: Origin` is emitted whenever an Origin header is present (matched
// or not) so any intermediate cache keys the response per-origin and a
// later request with a different origin cannot be served a stale ACAO.
func (ss *StreamServer) writeCORSHeaders(w http.ResponseWriter, r *http.Request, expose string) (preflight bool) {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
w.Header().Add("Vary", "Origin")
if _, ok := ss.corsAllowlist[origin]; !ok {
// Unknown origin — do not emit CORS headers so the browser blocks
// the response. Still return without short-circuiting so a non-CORS
// caller (e.g. curl) keeps working.
return false
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if expose != "" {
w.Header().Set("Access-Control-Expose-Headers", expose)
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return true
}
return false
}
// HLS returns the HLS session registry for this server. Daemon code uses it
// to register a session when the backend asks for HLS playback.
func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls }
// Listen starts the HTTP server on the configured port. Call once at daemon startup. // Listen starts the HTTP server on the configured port. Call once at daemon startup.
func (ss *StreamServer) Listen(ctx context.Context) error { func (ss *StreamServer) Listen(ctx context.Context) error {
// Freeze the CORS allowlist before the first request can land. After
// this point the map is treated as read-only so handlers can probe it
// without locking.
ss.corsAllowlist = buildCORSAllowlist(ss.corsExtraOrigins)
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler) mux.HandleFunc("/stream", ss.handler)
mux.HandleFunc("/health", ss.healthHandler) mux.HandleFunc("/health", ss.healthHandler)
mux.HandleFunc("/playlist.m3u", ss.playlistHandler) mux.HandleFunc("/playlist.m3u", ss.playlistHandler)
mux.HandleFunc("/hls/", ss.hlsHandler)
// SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart) // SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart)
lc := net.ListenConfig{ lc := net.ListenConfig{
@ -190,16 +115,11 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
if tsIP := TailscaleIP(); tsIP != "" { if tsIP := TailscaleIP(); tsIP != "" {
ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port) ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port)
} }
if ss.enableUPnP { if !ss.disableUPnP {
mapping, err := SetupUPnP(ss.port) if mapping, err := SetupUPnP(ss.port); err == nil {
if err != nil {
log.Printf("[stream] UPnP setup failed: %v (only LAN/Tailscale clients will reach port %d)", err, ss.port)
} else {
ss.upnpMapping = mapping ss.upnpMapping = mapping
ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort) ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
} }
} else {
log.Printf("[stream] UPnP disabled — port %d not published to WAN (set downloads.enable_upnp = true to opt in)", ss.port)
} }
// Best single URL for backward compat: Tailscale > LAN > Public > localhost // Best single URL for backward compat: Tailscale > LAN > Public > localhost
@ -310,173 +230,23 @@ func (ss *StreamServer) IdleSince() time.Duration {
// Call only at daemon shutdown — NOT between file swaps. // Call only at daemon shutdown — NOT between file swaps.
func (ss *StreamServer) Shutdown(ctx context.Context) error { func (ss *StreamServer) Shutdown(ctx context.Context) error {
ss.upnpMapping.Remove() ss.upnpMapping.Remove()
if ss.hls != nil {
ss.hls.CloseAll()
}
if ss.server != nil { if ss.server != nil {
return ss.server.Shutdown(ctx) return ss.server.Shutdown(ctx)
} }
return nil return nil
} }
// hlsBaseURLs returns the per-network HLS base URLs for a given session.
// The web client picks the first reachable one — same fallback strategy as
// the legacy /stream URLs.
func (ss *StreamServer) hlsBaseURLs(sessionID string) StreamURLs {
var out StreamURLs
if ss.urls.LAN != "" {
out.LAN = strings.Replace(ss.urls.LAN, "/stream", "/hls/"+sessionID, 1)
}
if ss.urls.Tailscale != "" {
out.Tailscale = strings.Replace(ss.urls.Tailscale, "/stream", "/hls/"+sessionID, 1)
}
if ss.urls.Public != "" {
out.Public = strings.Replace(ss.urls.Public, "/stream", "/hls/"+sessionID, 1)
}
return out
}
// HLSURLsJSON returns base URLs for an HLS session as a JSON string for the
// session response payload.
func (ss *StreamServer) HLSURLsJSON(sessionID string) string {
urls := ss.hlsBaseURLs(sessionID)
b, _ := json.Marshal(urls)
return string(b)
}
// hlsHandler routes /hls/<sessionID>/<resource> to the matching HLSSession.
//
// Recognised resources:
//
// master.m3u8 — top-level playlist
// video/index.m3u8 — video media playlist
// video/init.mp4 — fMP4 init segment
// video/seg-<n>.m4s — video segment
// subs/sub-<n>.m3u8 — per-subtitle media playlist (synthesised)
// subs/sub-<n>.vtt — WebVTT subtitle (extracted by ffmpeg)
func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
ss.lastActivity.Store(time.Now().UnixNano())
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
return
}
rest := strings.TrimPrefix(r.URL.Path, "/hls/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "missing session id", http.StatusNotFound)
return
}
sessionID := parts[0]
// Reject malformed IDs with the same 404 we return for unknown sessions —
// no oracle for the accepted format.
if !validSessionID.MatchString(sessionID) {
http.Error(w, "hls session not found", http.StatusNotFound)
return
}
session := ss.hls.Get(sessionID)
if session == nil {
http.Error(w, "hls session not found", http.StatusNotFound)
return
}
if len(parts) == 1 {
http.Error(w, "missing resource", http.StatusNotFound)
return
}
resource := parts[1]
switch {
case resource == "master.m3u8":
session.ServeMaster(w, r)
case resource == "probe.json":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
_ = json.NewEncoder(w).Encode(session.ProbeInfo())
case resource == "video/index.m3u8":
session.ServeVideoPlaylist(w, r)
case resource == "video/init.mp4":
session.ServeInit(w, r)
case strings.HasPrefix(resource, "video/seg-") && strings.HasSuffix(resource, ".m4s"):
idxStr := strings.TrimSuffix(strings.TrimPrefix(resource, "video/seg-"), ".m4s")
idx, err := strconv.Atoi(idxStr)
if err != nil {
http.Error(w, "bad segment index", http.StatusBadRequest)
return
}
session.ServeSegment(w, r, idx)
case strings.HasPrefix(resource, "subs/sub-") && strings.HasSuffix(resource, ".m3u8"):
idxStr := strings.TrimSuffix(strings.TrimPrefix(resource, "subs/sub-"), ".m3u8")
idx, err := strconv.Atoi(idxStr)
if err != nil {
http.Error(w, "bad subtitle index", http.StatusBadRequest)
return
}
ss.serveSubtitlePlaylist(w, r, session, idx)
case strings.HasPrefix(resource, "subs/sub-") && strings.HasSuffix(resource, ".vtt"):
idxStr := strings.TrimSuffix(strings.TrimPrefix(resource, "subs/sub-"), ".vtt")
idx, err := strconv.Atoi(idxStr)
if err != nil {
http.Error(w, "bad subtitle index", http.StatusBadRequest)
return
}
session.ServeSubtitle(w, r, idx)
default:
http.Error(w, "unknown hls resource", http.StatusNotFound)
}
}
// serveSubtitlePlaylist generates a single-VTT-segment HLS playlist on the
// fly so hls.js can consume it as a regular subtitle rendition. The VTT file
// itself is extracted asynchronously by HLSSession.extractSubtitles.
func (ss *StreamServer) serveSubtitlePlaylist(w http.ResponseWriter, r *http.Request, session *HLSSession, idx int) {
if idx < 0 || idx >= len(session.probe.SubtitleTracks) {
http.Error(w, "subtitle out of range", http.StatusNotFound)
return
}
dur := session.durationSec
if dur < 1 {
dur = 1
}
body := strings.Builder{}
body.WriteString("#EXTM3U\n")
body.WriteString("#EXT-X-VERSION:3\n")
body.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
body.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", int(dur)+1))
body.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n")
body.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", dur))
body.WriteString(fmt.Sprintf("sub-%d.vtt\n", idx))
body.WriteString("#EXT-X-ENDLIST\n")
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-cache")
_, _ = io.WriteString(w, body.String())
}
// healthHandler responde con el estado del servidor en JSON. // healthHandler responde con el estado del servidor en JSON.
// Útil para diagnosticar conectividad desde redes remotas o Tailscale: // Útil para diagnosticar conectividad desde redes remotas o Tailscale:
// //
// curl http://<tailscale-ip>:<port>/health // curl http://<tailscale-ip>:<port>/health
func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) { func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
if ss.writeCORSHeaders(w, r, "") {
return
}
ss.mu.RLock() ss.mu.RLock()
provider := ss.provider provider := ss.provider
taskID := ss.taskID taskID := ss.taskID
ss.mu.RUnlock() ss.mu.RUnlock()
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
// Only expose filename/taskID/client to loopback callers (local diagnostics).
// Remote callers (LAN, Tailscale, UPnP public) get a minimal probe response
// so that scanners and unauthenticated peers cannot fingerprint the active
// download. The web stream-probe only checks HTTP 200 + Content-Type.
//
// Use net.IP.IsLoopback so we also accept ::ffff:127.0.0.1 (Linux dual-stack
// IPv4-mapped form) and reject the empty-string fallthrough when
// SplitHostPort fails on a malformed RemoteAddr — both would otherwise
// silently bypass the disclosure boundary.
parsedIP := net.ParseIP(clientIP)
isLocal := parsedIP != nil && parsedIP.IsLoopback()
type healthResponse struct { type healthResponse struct {
Status string `json:"status"` Status string `json:"status"`
@ -484,23 +254,19 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
File string `json:"file,omitempty"` File string `json:"file,omitempty"`
Task string `json:"task,omitempty"` Task string `json:"task,omitempty"`
Port int `json:"port"` Port int `json:"port"`
Client string `json:"client,omitempty"` Client string `json:"client"`
} }
resp := healthResponse{ resp := healthResponse{
Status: "ok", Status: "ok",
Port: ss.port, Port: ss.port,
Client: clientIP,
} }
if provider != nil { if provider != nil {
resp.Streaming = true resp.Streaming = true
} resp.File = provider.FileName()
if isLocal { resp.Task = taskID
resp.Client = clientIP if len(resp.Task) > 8 {
if provider != nil { resp.Task = resp.Task[:8]
resp.File = provider.FileName()
resp.Task = taskID
if len(resp.Task) > 8 {
resp.Task = resp.Task[:8]
}
} }
} }
@ -516,8 +282,15 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
// VLC fetches this playlist and applies the EXTVLCOPT directives automatically, // VLC fetches this playlist and applies the EXTVLCOPT directives automatically,
// enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile). // enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile).
func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) { func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) {
if ss.writeCORSHeaders(w, r, "") { // CORS — handle preflight before doing any work (consistent with handler)
return if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
} }
q := r.URL.Query() q := r.URL.Query()
@ -587,8 +360,17 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
return return
} }
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") { // CORS headers — only when browser sends Origin (HTTPS site → localhost)
return if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
} }
rawReader := provider.NewFileReader(r.Context()) rawReader := provider.NewFileReader(r.Context())

View file

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

View file

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

View file

@ -1,347 +0,0 @@
package engine
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
)
// streamSource abstracts the byte source consumed by the HLS transcoder.
// Two implementations:
// - diskFileSource — direct passthrough of the on-disk file.
// - transcodeSource — ffmpeg writes a fragmented MP4 to a temp file in
// real time; reads block briefly when callers ask for bytes ahead of
// the writer.
type streamSource interface {
ReadAt(p []byte, off int64) (int, error)
// Size returns the currently known size. For transcoded sources this
// grows as ffmpeg produces output; on Final() it's the final size.
Size() int64
// Final reports whether the source size is now stable (passthrough is
// always final, transcoder becomes final when ffmpeg exits).
Final() bool
// EstimatedSize returns the final size we expect to converge on. For
// passthrough it's the same as Size(). For transcoder it's a bitrate
// × duration estimate so the browser scrubber has something to anchor
// on; the real size will differ ±20%.
EstimatedSize() int64
FileName() string
Transcoded() bool
Close() error
}
// ─────────────────────────────────────────────────────────────────────────────
// disk passthrough
// ─────────────────────────────────────────────────────────────────────────────
type diskFileSource struct {
f *os.File
size int64
name string
}
func newDiskFileSource(path string) (*diskFileSource, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("stream source: open %s: %w", path, err)
}
stat, err := f.Stat()
if err != nil {
f.Close()
return nil, fmt.Errorf("stream source: stat %s: %w", path, err)
}
return &diskFileSource{f: f, size: stat.Size(), name: filepath.Base(path)}, nil
}
func (d *diskFileSource) ReadAt(p []byte, off int64) (int, error) {
return d.f.ReadAt(p, off)
}
func (d *diskFileSource) Size() int64 { return d.size }
func (d *diskFileSource) Final() bool { return true }
func (d *diskFileSource) EstimatedSize() int64 { return d.size }
func (d *diskFileSource) FileName() string { return d.name }
func (d *diskFileSource) Transcoded() bool { return false }
func (d *diskFileSource) Close() error { return d.f.Close() }
// ─────────────────────────────────────────────────────────────────────────────
// transcode source — ffmpeg → tmp file
// ─────────────────────────────────────────────────────────────────────────────
type transcodeSource struct {
tmpPath string
tmpFile *os.File
cmd *Transcoder
name string
estimate int64
ctx context.Context
notify chan struct{} // size grew or final flipped; cap=1, non-blocking send
size atomic.Int64
final atomic.Bool
failure atomic.Pointer[error]
startedAt time.Time
}
const (
// readBlockTimeout caps how long ReadAt waits for bytes that haven't
// been transcoded yet before returning EOF/io.ErrUnexpectedEOF. The
// pump treats EOF as "respond with whatever we have so far + RangeEnd"
// so the browser can re-request once more bytes appear.
readBlockTimeout = 30 * time.Second
)
func newTranscodeSource(
ctx context.Context,
srcPath string,
probe *StreamProbe,
action TranscodeAction,
opts TranscodeOpts,
displayName string,
) (*transcodeSource, error) {
tmpFile, err := os.CreateTemp("", "tc-stream-*.mp4")
if err != nil {
return nil, fmt.Errorf("transcode source: tmp file: %w", err)
}
tmpPath := tmpFile.Name()
tmpFile.Close()
args := buildFFmpegArgs(srcPath, opts)
// Override -f mp4 pipe:1 with output to our tmp file path (last 3 args).
if len(args) >= 3 && args[len(args)-1] == "pipe:1" {
args[len(args)-1] = tmpPath
}
// Spawn ffmpeg directly (not via NewTranscoder pipe) so it writes to
// disk in real time. We re-use the rest of TranscodeOpts wiring.
cmd, err := startTranscoderToFile(ctx, opts.FFmpegPath, args, nil)
if err != nil {
os.Remove(tmpPath)
return nil, err
}
estimate := estimateOutputSize(probe, opts)
t := &transcodeSource{
tmpPath: tmpPath,
cmd: cmd,
name: displayName,
estimate: estimate,
ctx: ctx,
notify: make(chan struct{}, 1),
startedAt: time.Now(),
}
// Re-open the tmp file for reading; ffmpeg keeps writing to it.
rf, err := os.Open(tmpPath)
if err != nil {
_ = cmd.Close()
os.Remove(tmpPath)
return nil, fmt.Errorf("transcode source: reopen tmp: %w", err)
}
t.tmpFile = rf
go t.watchSize(ctx)
go t.watchExit()
return t, nil
}
// signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a
// notification is already pending the new event is folded into it (callers
// always re-check size + final after waking, so a coalesced signal still
// produces correct behaviour).
func (t *transcodeSource) signalNotify() {
select {
case t.notify <- struct{}{}:
default:
}
}
// watchSize polls the temp file size every 200 ms and wakes any blocked
// ReadAt callers once new bytes arrive.
func (t *transcodeSource) watchSize(ctx context.Context) {
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
t.signalNotify()
return
case <-ticker.C:
}
if t.final.Load() {
t.signalNotify()
return
}
stat, err := os.Stat(t.tmpPath)
if err != nil {
continue
}
current := stat.Size()
if current > t.size.Load() {
t.size.Store(current)
t.signalNotify()
}
}
}
// watchExit waits for ffmpeg to exit (via Transcoder's single-Wait goroutine)
// and locks in the final size. A kill triggered by Close() is NOT a failure.
func (t *transcodeSource) watchExit() {
<-t.cmd.Done()
err := t.cmd.WaitErr()
if err != nil && !t.cmd.IsClosing() {
failure := fmt.Errorf("ffmpeg exited: %w (%s)", err, t.cmd.Stderr())
t.failure.Store(&failure)
}
if stat, err := os.Stat(t.tmpPath); err == nil {
t.size.Store(stat.Size())
}
t.final.Store(true)
t.signalNotify()
}
// loadFailure returns the current failure (or nil) without taking a lock.
func (t *transcodeSource) loadFailure() error {
if p := t.failure.Load(); p != nil {
return *p
}
return nil
}
func (t *transcodeSource) ReadAt(p []byte, off int64) (int, error) {
if err := t.loadFailure(); err != nil {
return 0, err
}
if len(p) == 0 {
return 0, nil
}
if off < 0 {
return 0, fmt.Errorf("transcode source: negative offset %d", off)
}
want := int64(len(p))
deadline := time.Now().Add(readBlockTimeout)
for {
if t.final.Load() {
break
}
size := t.size.Load()
// Overflow-safe form of "off + want <= size":
if size >= off && size-off >= want {
break
}
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
wait := 500 * time.Millisecond
if remaining < wait {
wait = remaining
}
select {
case <-t.ctx.Done():
return 0, t.ctx.Err()
case <-t.notify:
case <-time.After(wait):
}
}
if err := t.loadFailure(); err != nil {
return 0, err
}
n, err := t.tmpFile.ReadAt(p, off)
// On a growing file ReadAt returns io.EOF when reading past current size.
// Translate that into "send what we have, RangeEnd will follow" by
// returning (n, nil) so the pump treats the data as a partial chunk and
// caller re-requests once more bytes appear. Only true EOF (final=true)
// propagates as io.EOF.
if err == io.EOF && !t.final.Load() {
if n > 0 {
return n, nil
}
return 0, errors.New("transcode source: read timed out waiting for ffmpeg output")
}
return n, err
}
func (t *transcodeSource) Size() int64 { return t.size.Load() }
func (t *transcodeSource) Final() bool { return t.final.Load() }
func (t *transcodeSource) EstimatedSize() int64 {
if t.final.Load() {
return t.size.Load()
}
return t.estimate
}
func (t *transcodeSource) FileName() string {
// Output is always fragmented MP4 regardless of source extension.
return strings.TrimSuffix(t.name, filepath.Ext(t.name)) + ".mp4"
}
func (t *transcodeSource) Transcoded() bool { return true }
func (t *transcodeSource) Close() error {
var errs []error
if err := t.cmd.Close(); err != nil {
errs = append(errs, err)
}
if t.tmpFile != nil {
if err := t.tmpFile.Close(); err != nil {
errs = append(errs, err)
}
}
if t.tmpPath != "" {
if err := os.Remove(t.tmpPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// estimateOutputSize converts probed bitrate × duration into a byte estimate
// so the browser scrubber has something to anchor on while transcoding.
func estimateOutputSize(probe *StreamProbe, opts TranscodeOpts) int64 {
if probe == nil || probe.DurationSec <= 0 {
return 0
}
videoKbps := parseBitrateKbps(opts.VideoBitrate, 5000)
audioKbps := parseBitrateKbps(opts.AudioBitrate, 192)
totalKbps := videoKbps + audioKbps
bytesPerSec := int64(totalKbps) * 1000 / 8
return int64(probe.DurationSec) * bytesPerSec
}
// parseBitrateKbps converts ffmpeg-style bitrate strings ("5M", "192k") to
// kilobits per second. Unknown formats fall back to fallback.
func parseBitrateKbps(s string, fallback int) int {
if s == "" {
return fallback
}
last := s[len(s)-1]
num := s
mult := 1
switch last {
case 'k', 'K':
num = s[:len(s)-1]
case 'M', 'm':
num = s[:len(s)-1]
mult = 1000
default:
// already in bps? treat as kbps
}
v := 0
for _, c := range num {
if c < '0' || c > '9' {
return fallback
}
v = v*10 + int(c-'0')
}
if v == 0 {
return fallback
}
return v * mult
}

View file

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

130
internal/engine/strm.go Normal file
View file

@ -0,0 +1,130 @@
package engine
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/torrentclaw/unarr/internal/agent"
)
// StrmDest holds the resolved location for a .strm file to be written.
type StrmDest struct {
Dir string // directory the .strm file lives in (created if missing)
FileName string // filename including the .strm extension
FullPath string // Dir + FileName, joined
}
// StrmDestForTask computes where a .strm file should land for the given
// agent task, mirroring organize()'s naming so Plex/Jellyfin sees the same
// folder structure as a real download would have produced.
//
// Returns an error if cfg lacks the relevant library directory.
func StrmDestForTask(task agent.Task, cfg OrganizeConfig) (StrmDest, error) {
switch {
case task.ContentType == "show":
if cfg.TVShowsDir == "" {
return StrmDest{}, fmt.Errorf("strm: TVShowsDir not configured")
}
showName := task.ContentTitle
if showName == "" {
showName = cleanTitle(task.Title)
}
dir := filepath.Join(cfg.TVShowsDir, sanitizePath(showName))
var fileName string
if task.Season != nil {
dir = filepath.Join(dir, fmt.Sprintf("Season %02d", *task.Season))
if task.Episode != nil {
fileName = fmt.Sprintf("%s - S%02dE%02d.strm",
sanitizePath(showName), *task.Season, *task.Episode)
}
}
if fileName == "" {
// Missing season/episode metadata — fall back to a sanitised title.
fileName = sanitizePath(showName) + ".strm"
}
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
case task.CollectionName != "" && cfg.MoviesDir != "":
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := strYear(task)
base := sanitizePath(movieName)
if year != "" {
base = fmt.Sprintf("%s (%s)", base, year)
}
dir := filepath.Join(cfg.MoviesDir, sanitizePath(task.CollectionName), base)
fileName := base + ".strm"
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
case task.ContentType == "movie":
if cfg.MoviesDir == "" {
return StrmDest{}, fmt.Errorf("strm: MoviesDir not configured")
}
movieName := task.ContentTitle
if movieName == "" {
movieName = cleanTitle(task.Title)
}
year := strYear(task)
base := sanitizePath(movieName)
if year != "" {
base = fmt.Sprintf("%s (%s)", base, year)
}
dir := filepath.Join(cfg.MoviesDir, base)
fileName := base + ".strm"
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
default:
// No metadata at all — drop into MoviesDir under a sanitised title so
// at least the file lands somewhere a library scan might find it.
if cfg.MoviesDir == "" {
return StrmDest{}, fmt.Errorf("strm: no library dir configured for content without metadata")
}
base := sanitizePath(cleanTitle(task.Title))
if base == "" {
base = "Unknown"
}
dir := filepath.Join(cfg.MoviesDir, base)
fileName := base + ".strm"
return StrmDest{Dir: dir, FileName: fileName, FullPath: filepath.Join(dir, fileName)}, nil
}
}
// WriteStrm writes a .strm file containing the given URL at the destination
// computed from the task. Creates parent dirs as needed. Atomic write
// (temp + rename) so a partial file never gets indexed.
func WriteStrm(task agent.Task, cfg OrganizeConfig) (string, error) {
if task.DirectURL == "" {
return "", fmt.Errorf("strm: task has no directUrl")
}
dest, err := StrmDestForTask(task, cfg)
if err != nil {
return "", err
}
if err := os.MkdirAll(dest.Dir, 0o755); err != nil {
return "", fmt.Errorf("strm: create dir: %w", err)
}
tmp := dest.FullPath + ".tmp"
if err := os.WriteFile(tmp, []byte(strings.TrimSpace(task.DirectURL)+"\n"), 0o644); err != nil {
return "", fmt.Errorf("strm: write temp: %w", err)
}
if err := os.Rename(tmp, dest.FullPath); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("strm: rename: %w", err)
}
return dest.FullPath, nil
}
// strYear is the agent.Task counterpart to organize.go's resolveYear.
func strYear(task agent.Task) string {
if task.ContentYear != nil && *task.ContentYear > 0 {
return fmt.Sprintf("%d", *task.ContentYear)
}
return yearRegex.FindString(task.Title)
}

View file

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

View file

@ -17,7 +17,6 @@ import (
"github.com/anacrolix/torrent" "github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage" "github.com/anacrolix/torrent/storage"
"github.com/torrentclaw/unarr/internal/config" "github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term" "golang.org/x/term"
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
@ -61,12 +60,7 @@ var defaultTrackers = []string{
// TorrentConfig holds settings for the BitTorrent downloader. // TorrentConfig holds settings for the BitTorrent downloader.
type TorrentConfig struct { type TorrentConfig struct {
DataDir string DataDir string
// PieceCompletionDir, when non-empty, stores the piece-completion SQLite DB
// in this directory instead of DataDir. Use the agent's local state dir
// (not the download dir) so the DB never lands on NFS/SMB volumes where
// SQLite locking times out.
PieceCompletionDir string
MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited) MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited)
StallTimeout time.Duration // no progress during download for this long = stall (default 10m) StallTimeout time.Duration // no progress during download for this long = stall (default 10m)
MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited) MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
@ -76,11 +70,6 @@ type TorrentConfig struct {
SeedEnabled bool SeedEnabled bool
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime) SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
SeedTime time.Duration // min seed time after completion (default 0) SeedTime time.Duration // min seed time after completion (default 0)
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
// add-on). nil = downloads in the clear. Brought up by the daemon.
VPNTunnel *vpn.Tunnel
} }
// TorrentDownloader downloads torrents via BitTorrent P2P. // TorrentDownloader downloads torrents via BitTorrent P2P.
@ -108,33 +97,14 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg.DataDir = cfg.DataDir tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !cfg.SeedEnabled tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning) tcfg.Logger = alog.Default.FilterLevel(alog.Critical)
// No browser-facing WebTorrent peer; daemon never seeds via WSS.
tcfg.DisableWebtorrent = true
// --- Performance optimizations --- // --- Performance optimizations ---
// Storage: mmap instead of default file backend. // Storage: mmap instead of default file backend.
// The library author notes file storage has "very high system overhead". // The library author notes file storage has "very high system overhead".
// mmap improves I/O throughput and piece verification speed significantly. // mmap improves I/O throughput and piece verification speed significantly.
// tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
// When PieceCompletionDir is set (daemon always passes the agent state dir),
// keep the piece-completion SQLite DB off the download dir so it never lands
// on NFS/SMB where SQLite's file locking times out and emits a warning.
if cfg.PieceCompletionDir != "" {
if mkErr := os.MkdirAll(cfg.PieceCompletionDir, 0o755); mkErr != nil {
log.Printf("[torrent] piece-completion dir create failed (%v), DB stays in download dir", mkErr)
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
} else if pc, pcErr := storage.NewDefaultPieceCompletionForDir(cfg.PieceCompletionDir); pcErr != nil {
log.Printf("[torrent] piece-completion db in %q failed (%v), falling back to download dir", cfg.PieceCompletionDir, pcErr)
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
} else {
tcfg.DefaultStorage = storage.NewMMapWithCompletion(cfg.DataDir, pc)
}
} else {
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
}
// Fixed port for incoming peer connections (enables UPnP port mapping). // Fixed port for incoming peer connections (enables UPnP port mapping).
// With ListenPort=0, only ~30% of peers can connect to us. // With ListenPort=0, only ~30% of peers can connect to us.
@ -221,20 +191,6 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// Re-announce active torrents to DHT periodically (keeps routing table healthy). // Re-announce active torrents to DHT periodically (keeps routing table healthy).
tcfg.PeriodicallyAnnounceTorrentsToDht = true tcfg.PeriodicallyAnnounceTorrentsToDht = true
// --- Managed-VPN split-tunnel ---
// Route the torrent client's outbound peer + tracker traffic through the
// in-process WireGuard tunnel so the swarm + trackers see the VPN IP, not
// the user's. unarr's control plane keeps using the normal net. uTP (UDP
// peers) is disabled — TCP peers + HTTP/UDP tracker announces are tunnelled;
// inbound peers don't apply (leech-only, no port forward).
if cfg.VPNTunnel != nil {
tcfg.DisableUTP = true
tcfg.TrackerDialContext = cfg.VPNTunnel.Net.DialContext
tcfg.HTTPDialContext = cfg.VPNTunnel.Net.DialContext
tcfg.TrackerListenPacket = cfg.VPNTunnel.ListenPacket
log.Printf("[torrent] VPN split-tunnel enabled (peer + tracker traffic routed through WireGuard)")
}
// Try to create client; if the port is in use, try the next few ports. // Try to create client; if the port is in use, try the next few ports.
var client *torrent.Client var client *torrent.Client
var err error var err error
@ -256,12 +212,6 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort) log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort)
} }
// Route outgoing peer dials through the VPN tunnel (TCP). Added after client
// creation; DialForPeerConns defaults to true so this is used for peers.
if cfg.VPNTunnel != nil {
client.AddDialer(torrent.NetworkDialer{Network: "tcp", Dialer: cfg.VPNTunnel.Net})
}
// Restore DHT nodes with full node IDs (direct routing table insertion, no async pings). // Restore DHT nodes with full node IDs (direct routing table insertion, no async pings).
for _, s := range client.DhtServers() { for _, s := range client.DhtServers() {
if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok { if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok {
@ -285,7 +235,7 @@ func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, erro
} }
func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) { func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) {
magnet := d.buildMagnet(task.InfoHash) magnet := buildMagnet(task.InfoHash)
t, err := d.client.AddMagnet(magnet) t, err := d.client.AddMagnet(magnet)
if err != nil { if err != nil {
@ -373,13 +323,6 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
result.Method = MethodTorrent result.Method = MethodTorrent
result.Size = totalBytes result.Size = totalBytes
// anacrolix mmap storage (storage.NewMMap) creates completed files with mode
// 0000 — the running process keeps its own mmap handle so the download works,
// but any fresh open (streaming, ffprobe/HLS, organize-then-reopen) hits
// "permission denied". Relax perms now, before organize moves the file, so the
// readable mode is preserved through the rename.
makeReadable(filePath)
// If seeding enabled, keep alive (don't cleanup). // If seeding enabled, keep alive (don't cleanup).
// The manager handles seeding lifecycle. // The manager handles seeding lifecycle.
if !d.cfg.SeedEnabled { if !d.cfg.SeedEnabled {
@ -487,41 +430,6 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
} }
} }
// makeReadable relaxes permissions on a completed download so it can be
// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
// files with mode 0000; we set files to 0644 and directories to 0755. Errors
// are logged but non-fatal (e.g. NFS root_squash) — the file may still be
// readable depending on the export.
func makeReadable(path string) {
info, err := os.Stat(path)
if err != nil {
log.Printf("[organize] makeReadable stat %q: %v", path, err)
return
}
if !info.IsDir() {
if err := os.Chmod(path, 0o644); err != nil {
log.Printf("[organize] makeReadable chmod %q: %v", path, err)
}
return
}
err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // skip unreadable entries, keep going
}
mode := os.FileMode(0o644)
if d.IsDir() {
mode = 0o755
}
if err := os.Chmod(p, mode); err != nil {
log.Printf("[organize] makeReadable chmod %q: %v", p, err)
}
return nil
})
if err != nil {
log.Printf("[organize] makeReadable walk %q: %v", path, err)
}
}
// Pause drops the torrent handle but keeps partial files on disk for resume. // Pause drops the torrent handle but keeps partial files on disk for resume.
func (d *TorrentDownloader) Pause(taskID string) error { func (d *TorrentDownloader) Pause(taskID string) error {
d.activeMu.Lock() d.activeMu.Lock()
@ -696,8 +604,6 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota
return totalBytes, fileName return totalBytes, fileName
} }
// buildMagnet composes a magnet URI for the info hash with the static
// tracker list.
func buildMagnet(infoHash string) string { func buildMagnet(infoHash string) string {
params := []string{"xt=urn:btih:" + infoHash} params := []string{"xt=urn:btih:" + infoHash}
for _, tracker := range defaultTrackers { for _, tracker := range defaultTrackers {
@ -706,10 +612,6 @@ func buildMagnet(infoHash string) string {
return "magnet:?" + strings.Join(params, "&") return "magnet:?" + strings.Join(params, "&")
} }
func (d *TorrentDownloader) buildMagnet(infoHash string) string {
return buildMagnet(infoHash)
}
func formatBytes(b int64) string { func formatBytes(b int64) string {
const unit = 1024 const unit = 1024
if b < unit { if b < unit {

View file

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

View file

@ -1,306 +0,0 @@
package engine
import (
"context"
"fmt"
"io"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// TranscodeOpts steers how Transcoder builds its ffmpeg command line.
//
// - Output: fragmented MP4 chunked into HLS segments by the muxer.
// - Audio: AAC stereo @ 192kbps unless source already AAC (then -c:a copy).
// - Video: copy when h264 8-bit; otherwise transcode to h264 with HW encode
// when available, software fallback at "veryfast" preset.
type TranscodeOpts struct {
Action TranscodeAction
HWAccel HWAccel
Preset string // "veryfast" / "fast" / "medium"
VideoBitrate string // e.g. "5M"
AudioBitrate string // e.g. "192k"
MaxHeight int // optional downscale cap (e.g. 720)
SourceHeight int // probed source height — used to derive a sane H.264 level
StartSeconds float64
FFmpegPath string
}
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
// fragmented MP4 bytes; the HLS muxer slices them into segments served over HTTP.
//
// One Transcoder == one playback position. A seek beyond the buffered window
// requires Close()ing this transcoder and starting a new one with a higher
// StartSeconds (handled by the HLS session at ffmpeg start time).
//
// A single internal goroutine owns cmd.Wait() — never call cmd.Wait()
// directly from outside (os/exec forbids concurrent Wait callers). Use
// Done() / WaitErr() instead.
type Transcoder struct {
cmd *exec.Cmd
out io.ReadCloser
mu sync.Mutex
closed bool
stderr strings.Builder
done chan struct{} // closed once cmd.Wait returns; nil if cmd never started
waitErr error // populated before done is closed; read-only after
}
// NewTranscoder spawns ffmpeg and returns a Transcoder whose Read() yields
// fragmented MP4 bytes from stdin. Callers MUST call Close() when done.
func NewTranscoder(ctx context.Context, filePath string, opts TranscodeOpts) (*Transcoder, error) {
if opts.FFmpegPath == "" {
return nil, fmt.Errorf("transcoder: empty ffmpeg path")
}
args := buildFFmpegArgs(filePath, opts)
cmd := exec.CommandContext(ctx, opts.FFmpegPath, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("transcoder: stdout pipe: %w", err)
}
t := &Transcoder{cmd: cmd, out: stdout}
cmd.Stderr = &errWriter{t: t}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
}
t.startWaitGoroutine()
return t, nil
}
// startTranscoderToFile spawns ffmpeg with a pre-built argv where the last
// argument is an output file path (instead of pipe:1). Used by streamSource
// when we want random-access reads against a growing temp file rather than
// sequential pipe consumption.
func startTranscoderToFile(ctx context.Context, ffmpegPath string, args []string, t *Transcoder) (*Transcoder, error) {
if ffmpegPath == "" {
return nil, fmt.Errorf("transcoder: empty ffmpeg path")
}
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
if t == nil {
t = &Transcoder{}
}
t.cmd = cmd
cmd.Stderr = &errWriter{t: t}
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("transcoder: start ffmpeg: %w", err)
}
t.startWaitGoroutine()
return t, nil
}
// startWaitGoroutine launches the single goroutine that owns cmd.Wait().
// Idempotent — protected by sync.Once-via-nil-check on done.
func (t *Transcoder) startWaitGoroutine() {
if t.done != nil {
return
}
t.done = make(chan struct{})
go func() {
t.waitErr = t.cmd.Wait()
close(t.done)
}()
}
// Done returns a channel that closes when ffmpeg exits. Returns nil for a
// Transcoder whose cmd never started.
func (t *Transcoder) Done() <-chan struct{} { return t.done }
// WaitErr blocks until ffmpeg exits and returns the wait error. Safe to
// call concurrently from multiple goroutines.
func (t *Transcoder) WaitErr() error {
if t.done == nil {
return nil
}
<-t.done
return t.waitErr
}
// Read implements io.Reader.
func (t *Transcoder) Read(p []byte) (int, error) { return t.out.Read(p) }
// Close kills the child process if still running and waits up to 2s for exit.
// IsClosing reports true after Close has been invoked — used by streamSource
// to distinguish a kill-by-Close from a genuine ffmpeg crash.
func (t *Transcoder) Close() error {
t.mu.Lock()
if t.closed {
t.mu.Unlock()
return nil
}
t.closed = true
t.mu.Unlock()
// out is nil for the file-output flow (startTranscoderToFile) — that
// pipeline writes directly to a temp file via -i ... output_path so we
// never wired a stdout pipe. Only close when present.
if t.out != nil {
_ = t.out.Close()
}
if t.cmd != nil && t.cmd.Process != nil {
_ = t.cmd.Process.Kill()
}
if t.done == nil {
return nil
}
select {
case <-t.done:
case <-time.After(2 * time.Second):
// Process refused to die — leak it; the OS will clean up on exit.
}
return nil
}
// IsClosing reports whether Close has been invoked. Cheap atomic-ish check
// for callers that want to distinguish a kill-by-Close exit from a real
// ffmpeg failure when reading WaitErr.
func (t *Transcoder) IsClosing() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.closed
}
// Stderr returns the accumulated ffmpeg stderr so far. Useful for surfacing
// failure reasons in logs after Close().
func (t *Transcoder) Stderr() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.stderr.String()
}
// errWriter funnels ffmpeg stderr into the Transcoder buffer so it can be
// inspected post-mortem. Capped so a misbehaving ffmpeg can't grow memory.
type errWriter struct{ t *Transcoder }
func (w *errWriter) Write(p []byte) (int, error) {
w.t.mu.Lock()
defer w.t.mu.Unlock()
const maxBuf = 64 * 1024
if w.t.stderr.Len() < maxBuf {
w.t.stderr.Write(p)
}
return len(p), nil
}
// buildFFmpegArgs assembles the command line for the requested action.
// Exposed package-level so tests can lock the flag matrix independently of
// process spawning.
func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
// -y: overwrite output without asking (the file-output flow uses an
// already-created tmp file from os.CreateTemp, so the default "do you
// want to overwrite?" prompt would deadlock on stdin and ffmpeg dies
// before producing a single byte). Pipe flow doesn't need it but it's
// harmless there.
args := []string{"-y", "-hide_banner", "-loglevel", "warning"}
// Seek BEFORE input (-ss before -i) for fast keyframe-aligned start.
if opts.StartSeconds > 0 {
args = append(args, "-ss", strconv.FormatFloat(opts.StartSeconds, 'f', 3, 64))
}
// HW accel hint on the demuxer side improves throughput for HEVC inputs
// even when we end up encoding in software. Skip on macOS (videotoolbox
// uses a different flag shape).
switch opts.HWAccel {
case HWAccelNVENC:
args = append(args, "-hwaccel", "cuda")
case HWAccelQSV:
args = append(args, "-hwaccel", "qsv")
case HWAccelVAAPI:
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi")
case HWAccelNone, HWAccelVideoToolbox:
// No demuxer-side hint: software decode (None) or per-encoder flags
// already applied separately by FFmpegVideoCodec (VideoToolbox).
}
args = append(args, "-i", filePath)
switch opts.Action {
case ActionPassthrough, ActionRemux:
args = append(args, "-c:v", "copy", "-c:a", "copy")
case ActionRemuxAudio:
args = append(args, "-c:v", "copy", "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
case ActionTranscodeVideo:
videoCodec := opts.HWAccel.FFmpegVideoCodec("h264")
args = append(args, "-c:v", videoCodec)
if videoCodec == "libx264" {
args = append(args, "-preset", coalesce(opts.Preset, "veryfast"))
}
// Force the broadest browser-compatible h264 profile. `high` (libx264
// default) makes Chrome try its hardware decoder path first, which
// can fail with "VaapiWrapper: failed initializing" on Linux boxes
// where VA-API isn't fully wired up. `main` keeps a clean software
// decode fallback on every desktop + mobile platform.
//
// Level is derived from the actual output height — a fixed "4.0"
// silently rejects 4K and 1440p sources at the libx264 macroblock
// limits and produces unplayable streams. opts.MaxHeight is the
// downscale cap when set; falling through means "encode at source".
levelHeight := opts.MaxHeight
if levelHeight == 0 || (opts.SourceHeight > 0 && opts.SourceHeight < levelHeight) {
levelHeight = opts.SourceHeight
}
args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(levelHeight))
args = append(args, "-b:v", coalesce(opts.VideoBitrate, "5M"))
// Filter chain:
// 1. scale (optional) — cap height + force even width.
// 2. format=yuv420p — drop 10-bit + reset pix_fmt to 8-bit before
// libx264 (which refuses 10-bit unless built with --bit-depth=10).
// 3. setparams — REWRITE the color metadata in the output stream's
// VUI/SEI without touching pixels. This is what makes HDR HEVC
// sources (color_primaries=bt2020, color_transfer=arib-std-b67)
// decodeable in browsers that reject anything but Rec.709. We
// can't actually tonemap without libzimg/zscale (most ffmpeg
// builds — including ours — ship without it), so colours look
// desaturated on HDR sources, but the file plays. SDR sources
// already match these params and are unaffected.
var filterChain string
if opts.MaxHeight > 0 {
filterChain = fmt.Sprintf(
"scale=-2:%d:force_original_aspect_ratio=decrease,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
opts.MaxHeight,
)
} else {
filterChain = "format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
}
args = append(args, "-vf", filterChain)
// Force AAC-LC stereo 48 kHz so the hls.js demuxer accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape the demuxer refuses
// to parse, so always downmix to stereo + resample to 48 kHz here.
args = append(args,
"-c:a", "aac",
"-b:a", coalesce(opts.AudioBitrate, "192k"),
"-ar", "48000",
"-ac", "2",
)
}
// Common output flags — fragmented MP4 to a single pipe.
//
// * empty_moov + default_base_moof: header-only init segment up front
// so the demuxer can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media.
// Without it ffmpeg only splits at keyframes; a high-bitrate 1080p
// stream produces 8 MiB+ mdat boxes that delay the first fragment
// until the whole mdat lands and playback never starts.
// * negative_cts_offsets: lets b-frames carry the right pts/dts so
// decoders don't reset the playhead to 0 every fragment.
args = append(args,
"-movflags", "+frag_keyframe+empty_moov+default_base_moof+negative_cts_offsets",
"-frag_duration", "1000000",
"-f", "mp4",
"pipe:1",
)
return args
}
func coalesce(s, fallback string) string {
if s == "" {
return fallback
}
return s
}

View file

@ -1,210 +0,0 @@
package engine
import (
"strings"
"testing"
)
func sliceContains(args []string, want string) bool {
for _, a := range args {
if a == want {
return true
}
}
return false
}
func sliceContainsPair(args []string, key, val string) bool {
for i := 0; i < len(args)-1; i++ {
if args[i] == key && args[i+1] == val {
return true
}
}
return false
}
func TestBuildFFmpegArgsPassthroughCopy(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mp4", TranscodeOpts{
Action: ActionPassthrough,
HWAccel: HWAccelNone,
FFmpegPath: "ffmpeg",
})
if !sliceContainsPair(args, "-c:v", "copy") {
t.Errorf("passthrough should keep -c:v copy. args=%v", args)
}
if !sliceContainsPair(args, "-c:a", "copy") {
t.Error("passthrough should keep -c:a copy")
}
if !sliceContainsPair(args, "-f", "mp4") {
t.Error("output container must be mp4")
}
movflags := ""
for i := 0; i < len(args)-1; i++ {
if args[i] == "-movflags" {
movflags = args[i+1]
}
}
if !strings.Contains(movflags, "frag_keyframe") {
t.Errorf("movflags must include frag_keyframe, got %q", movflags)
}
}
func TestBuildFFmpegArgsRemuxAudio(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
Action: ActionRemuxAudio,
AudioBitrate: "256k",
FFmpegPath: "ffmpeg",
})
if !sliceContainsPair(args, "-c:v", "copy") {
t.Error("remux-audio keeps video copy")
}
if !sliceContainsPair(args, "-c:a", "aac") {
t.Error("remux-audio must transcode audio to aac")
}
if !sliceContainsPair(args, "-b:a", "256k") {
t.Error("audio bitrate override not honored")
}
}
func TestBuildFFmpegArgsTranscodeVideoSoftware(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
Action: ActionTranscodeVideo,
HWAccel: HWAccelNone,
Preset: "fast",
VideoBitrate: "6M",
FFmpegPath: "ffmpeg",
})
if !sliceContainsPair(args, "-c:v", "libx264") {
t.Error("software fallback must use libx264")
}
if !sliceContainsPair(args, "-preset", "fast") {
t.Error("custom preset not honored")
}
if !sliceContainsPair(args, "-b:v", "6M") {
t.Error("video bitrate not honored")
}
}
func TestBuildFFmpegArgsTranscodeVideoNVENC(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
Action: ActionTranscodeVideo,
HWAccel: HWAccelNVENC,
FFmpegPath: "ffmpeg",
})
if !sliceContainsPair(args, "-hwaccel", "cuda") {
t.Error("NVENC must request -hwaccel cuda")
}
if !sliceContainsPair(args, "-c:v", "h264_nvenc") {
t.Error("NVENC must use h264_nvenc encoder")
}
if sliceContains(args, "-preset") {
// HW encoders ignore software preset; we should NOT pass it.
t.Error("HW encoder path should not include -preset")
}
}
func TestBuildFFmpegArgsAddsStartSeek(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mp4", TranscodeOpts{
Action: ActionPassthrough,
StartSeconds: 90.5,
FFmpegPath: "ffmpeg",
})
idxSs, idxIn := -1, -1
for i, a := range args {
if a == "-ss" {
idxSs = i
}
if a == "-i" {
idxIn = i
}
}
if idxSs < 0 {
t.Fatal("missing -ss flag")
}
if idxIn < 0 {
t.Fatal("missing -i flag")
}
if idxSs >= idxIn {
t.Errorf("expected -ss BEFORE -i for fast seek; got -ss@%d -i@%d", idxSs, idxIn)
}
if args[idxSs+1] != "90.500" {
t.Errorf("expected seek 90.500s, got %q", args[idxSs+1])
}
}
func TestTranscoderZeroValueLifecycle(t *testing.T) {
var tr Transcoder
if tr.IsClosing() {
t.Errorf("zero-value Transcoder should not report IsClosing")
}
if tr.Stderr() != "" {
t.Errorf("zero-value Stderr should be empty")
}
if err := tr.WaitErr(); err != nil {
t.Errorf("WaitErr without started cmd should be nil, got %v", err)
}
if err := tr.Close(); err != nil {
t.Errorf("Close without started cmd should be nil, got %v", err)
}
// Second Close is idempotent and must remain nil.
if err := tr.Close(); err != nil {
t.Errorf("repeat Close should be nil, got %v", err)
}
if !tr.IsClosing() {
t.Errorf("after Close, IsClosing should be true")
}
if tr.Done() != nil {
t.Errorf("Done() should be nil for never-started Transcoder")
}
}
func TestErrWriterCapturesStderr(t *testing.T) {
tr := &Transcoder{}
w := &errWriter{t: tr}
n, err := w.Write([]byte("ffmpeg failed: bad codec"))
if err != nil || n != 24 {
t.Errorf("Write returned (%d,%v)", n, err)
}
if got := tr.Stderr(); got != "ffmpeg failed: bad codec" {
t.Errorf("Stderr captured %q", got)
}
}
func TestErrWriterCapsBuffer(t *testing.T) {
tr := &Transcoder{}
w := &errWriter{t: tr}
// Write a chunk under the cap, then a huge chunk: total should stop growing past 64KB.
w.Write(make([]byte, 32*1024)) //nolint:errcheck
w.Write(make([]byte, 32*1024)) //nolint:errcheck
w.Write(make([]byte, 32*1024)) //nolint:errcheck
if got := len(tr.Stderr()); got > 64*1024 {
t.Errorf("stderr exceeded 64KB cap: %d bytes", got)
}
}
func TestCoalesce(t *testing.T) {
if got := coalesce("", "fallback"); got != "fallback" {
t.Errorf("empty -> fallback, got %q", got)
}
if got := coalesce("value", "fallback"); got != "value" {
t.Errorf("non-empty -> value, got %q", got)
}
}
func TestBuildFFmpegArgsDownscale(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
Action: ActionTranscodeVideo,
HWAccel: HWAccelNone,
MaxHeight: 720,
FFmpegPath: "ffmpeg",
})
hasVF := false
for i := 0; i < len(args)-1; i++ {
if args[i] == "-vf" && strings.Contains(args[i+1], "720") {
hasVF = true
}
}
if !hasVF {
t.Errorf("expected -vf scale containing 720; args=%v", args)
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,40 +0,0 @@
package funnel
import "testing"
func TestURLPattern(t *testing.T) {
cases := []struct {
name string
line string
want string
}{
{
name: "real quick tunnel banner",
line: "2026-05-29T22:18:33Z INF | https://make-appointments-negotiation-blacks.trycloudflare.com |",
want: "https://make-appointments-negotiation-blacks.trycloudflare.com",
},
{
name: "two-word hostname",
line: "https://blue-river.trycloudflare.com is ready",
want: "https://blue-river.trycloudflare.com",
},
{
name: "control-plane api endpoint is ignored",
line: `2026-05-29T22:17:59Z DBG POST https://api.trycloudflare.com/tunnel`,
want: "",
},
{
name: "no trycloudflare url",
line: "2026-05-29T22:17:44Z INF Requesting new quick Tunnel on trycloudflare.com...",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := urlPattern.FindString(tc.line); got != tc.want {
t.Fatalf("FindString(%q) = %q, want %q", tc.line, got, tc.want)
}
})
}
}

View file

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

View file

@ -1,79 +0,0 @@
package mediainfo
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
)
// ResolveFFmpeg finds the ffmpeg binary. Search order mirrors ResolveFFprobe
// so the same operator setup works for both:
// 1. Explicit path (--ffmpeg flag / library.ffmpeg_path config)
// 2. FFMPEG_PATH env var
// 3. "ffmpeg" on PATH
// 4. Adjacent to the current executable (release tarball bundles ffmpeg
// next to the unarr binary — this is the preferred install path)
// 5. Previously downloaded in the unarr cache dir
// 6. Auto-download static binary as last resort (~50MB, slow start)
//
// ffmpeg is required for the HLS streaming pipeline; ffprobe alone can't
// transcode HEVC/MKV to browser-friendly H.264/MP4 fragments.
func ResolveFFmpeg(explicit string) (string, error) {
if explicit != "" {
if _, err := os.Stat(explicit); err == nil {
return explicit, nil
}
return "", fmt.Errorf("ffmpeg not found at explicit path: %s", explicit)
}
if envPath := os.Getenv("FFMPEG_PATH"); envPath != "" {
if _, err := os.Stat(envPath); err == nil {
return envPath, nil
}
}
if p, err := exec.LookPath("ffmpeg"); err == nil {
return p, nil
}
if exePath, err := os.Executable(); err == nil {
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
adjacent := filepath.Join(filepath.Dir(exePath), name)
if _, err := os.Stat(adjacent); err == nil {
return adjacent, nil
}
}
if cached, err := FFmpegCachePath(); err == nil {
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
}
if p, err := DownloadFFmpeg(); err == nil {
return p, nil
}
if isDocker() {
return "", fmt.Errorf(
"ffmpeg not found and auto-download failed (read-only filesystem?).\n" +
"Options:\n" +
" • Use the official image: torrentclaw/unarr (includes ffmpeg)\n" +
" • Set FFMPEG_PATH env var to point to a pre-installed ffmpeg binary\n" +
" • Add to config.toml: [library]\\nffmpeg_path = \"/path/to/ffmpeg\"",
)
}
return "", fmt.Errorf(
"ffmpeg not found and auto-download failed.\n" +
"Options:\n" +
" • Install ffmpeg: sudo apt install ffmpeg (or brew install ffmpeg)\n" +
" • Use the unarr release tarball — ffmpeg is bundled next to the binary\n" +
" • Set FFMPEG_PATH env var to point to the ffmpeg binary\n" +
" • Add to config.toml: [library]\\nffmpeg_path = \"/path/to/ffmpeg\"",
)
}

View file

@ -1,116 +0,0 @@
package mediainfo
import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
)
const maxFFmpegZipSize = 200 * 1024 * 1024 // 200MB — ffmpeg static is ~70-100MB compressed
// FFmpegCachePath returns the full path to the cached ffmpeg binary
// (sibling of the cached ffprobe binary).
func FFmpegCachePath() (string, error) {
dir, err := FFprobeCacheDir()
if err != nil {
return "", err
}
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
return filepath.Join(dir, name), nil
}
// DownloadFFmpeg downloads a static ffmpeg binary for the current platform
// and caches it locally. Returns the path to the binary. Reuses
// resolveFFprobeURL's ffbinaries.com discovery endpoint — that index ships
// both ffprobe and ffmpeg per platform.
func DownloadFFmpeg() (string, error) {
dest, err := FFmpegCachePath()
if err != nil {
return "", fmt.Errorf("cannot determine cache path: %w", err)
}
if _, err := os.Stat(dest); err == nil {
return dest, nil
}
platform, err := ffprobePlatformKey()
if err != nil {
return "", err
}
url, err := resolveFFmpegURL(platform)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "ffmpeg not found — downloading for %s (~70MB)...\n", platform)
resp, err := ffprobeDLClient.Get(url)
if err != nil {
return "", fmt.Errorf("download failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode)
}
zipData, err := io.ReadAll(io.LimitReader(resp.Body, maxFFmpegZipSize))
if err != nil {
return "", fmt.Errorf("download read failed: %w", err)
}
name := "ffmpeg"
if runtime.GOOS == "windows" {
name = "ffmpeg.exe"
}
binary, err := extractFromZip(zipData, name)
if err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("cannot create cache directory: %w", err)
}
if err := os.WriteFile(dest, binary, 0o755); err != nil {
return "", fmt.Errorf("cannot write ffmpeg binary: %w", err)
}
fmt.Fprintf(os.Stderr, "ffmpeg installed to %s\n", dest)
return dest, nil
}
// resolveFFmpegURL fetches the ffbinaries index and returns the ffmpeg
// download URL for the requested platform key (e.g. "linux-64").
func resolveFFmpegURL(platform string) (string, error) {
resp, err := ffprobeAPIClient.Get(ffbinariesAPI)
if err != nil {
return "", fmt.Errorf("cannot reach ffbinaries.com: %w", err)
}
defer resp.Body.Close()
var data ffbinariesResponse
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return "", fmt.Errorf("cannot parse ffbinaries response: %w", err)
}
bins, ok := data.Bin[platform]
if !ok {
return "", fmt.Errorf("no ffmpeg binary available for platform %q", platform)
}
url, ok := bins["ffmpeg"]
if !ok {
return "", fmt.Errorf("no ffmpeg download URL for platform %q", platform)
}
return url, nil
}

View file

@ -1,78 +0,0 @@
package mediainfo
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// TestResolveFFmpeg_ExplicitOK verifies the explicit-path branch returns
// the requested binary if it exists on disk.
func TestResolveFFmpeg_ExplicitOK(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "ffmpeg")
if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write fake: %v", err)
}
got, err := ResolveFFmpeg(fake)
if err != nil {
t.Fatalf("ResolveFFmpeg(explicit): %v", err)
}
if got != fake {
t.Fatalf("got %q want %q", got, fake)
}
}
// TestResolveFFmpeg_ExplicitMissing returns a clear error when the path
// the operator supplied doesn't exist — we do NOT silently fall back.
func TestResolveFFmpeg_ExplicitMissing(t *testing.T) {
_, err := ResolveFFmpeg("/nonexistent/path/ffmpeg-XXXXXX")
if err == nil {
t.Fatal("expected error for missing explicit path")
}
}
// TestResolveFFmpeg_EnvVar honours FFMPEG_PATH when no explicit path is given.
func TestResolveFFmpeg_EnvVar(t *testing.T) {
dir := t.TempDir()
fake := filepath.Join(dir, "ffmpeg")
if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatalf("write fake: %v", err)
}
t.Setenv("FFMPEG_PATH", fake)
// Hide the real ffmpeg from PATH so the env var is the next branch hit.
t.Setenv("PATH", "/nonexistent")
got, err := ResolveFFmpeg("")
if err != nil {
t.Fatalf("ResolveFFmpeg(env): %v", err)
}
if got != fake {
t.Fatalf("got %q want %q (env-var branch)", got, fake)
}
}
// TestFFmpegCachePath returns a sibling path to the ffprobe cache,
// consistent with the install layout the tarball produces.
func TestFFmpegCachePath(t *testing.T) {
got, err := FFmpegCachePath()
if err != nil {
t.Fatalf("FFmpegCachePath: %v", err)
}
want := "ffmpeg"
if runtime.GOOS == "windows" {
want = "ffmpeg.exe"
}
if filepath.Base(got) != want {
t.Fatalf("cache path basename = %q want %q", filepath.Base(got), want)
}
probeCache, err := FFprobeCachePath()
if err != nil {
t.Fatalf("FFprobeCachePath: %v", err)
}
if filepath.Dir(got) != filepath.Dir(probeCache) {
t.Fatalf("ffmpeg cache (%s) and ffprobe cache (%s) should share a directory", got, probeCache)
}
}

View file

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

View file

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

View file

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

View file

@ -87,6 +87,17 @@ func Detect() DetectedPaths {
// ── Plex ──────────────────────────────────────────────────────────── // ── Plex ────────────────────────────────────────────────────────────
// LocalPlexToken returns the Plex auth token from the local Plex config
// directory, if Plex Media Server is installed on this host. Returns ""
// when Plex isn't installed or the token can't be read.
func LocalPlexToken() string {
dir := plexConfigDir()
if dir == "" {
return ""
}
return PlexTokenFromPrefs(filepath.Join(dir, "Preferences.xml"))
}
func plexLibraryPaths() []string { func plexLibraryPaths() []string {
configDir := plexConfigDir() configDir := plexConfigDir()
if configDir == "" { if configDir == "" {
@ -95,7 +106,7 @@ func plexLibraryPaths() []string {
// Read token from Preferences.xml // Read token from Preferences.xml
prefsPath := filepath.Join(configDir, "Preferences.xml") prefsPath := filepath.Join(configDir, "Preferences.xml")
token := plexTokenFromPrefs(prefsPath) token := PlexTokenFromPrefs(prefsPath)
if token == "" { if token == "" {
return nil return nil
} }
@ -154,7 +165,10 @@ type plexPrefs struct {
PlexOnlineToken string `xml:"PlexOnlineToken,attr"` PlexOnlineToken string `xml:"PlexOnlineToken,attr"`
} }
func plexTokenFromPrefs(path string) string { // PlexTokenFromPrefs reads the Plex auth token from a Preferences.xml file.
// Returns "" if the file can't be read or parsed. Used by the setup wizard
// when configuring a Plex server running on the same host as unarr.
func PlexTokenFromPrefs(path string) string {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return "" return ""

View file

@ -79,7 +79,7 @@ func TestPlexTokenFromPrefs(t *testing.T) {
<Preferences PlexOnlineToken="my-secret-token" OldestPreviousVersion="1.0"/>` <Preferences PlexOnlineToken="my-secret-token" OldestPreviousVersion="1.0"/>`
os.WriteFile(prefsPath, []byte(xml), 0o644) os.WriteFile(prefsPath, []byte(xml), 0o644)
token := plexTokenFromPrefs(prefsPath) token := PlexTokenFromPrefs(prefsPath)
if token != "my-secret-token" { if token != "my-secret-token" {
t.Errorf("token = %q, want my-secret-token", token) t.Errorf("token = %q, want my-secret-token", token)
} }
@ -91,14 +91,14 @@ func TestPlexTokenFromPrefs(t *testing.T) {
xml := `<?xml version="1.0"?><Preferences/>` xml := `<?xml version="1.0"?><Preferences/>`
os.WriteFile(prefsPath, []byte(xml), 0o644) os.WriteFile(prefsPath, []byte(xml), 0o644)
token := plexTokenFromPrefs(prefsPath) token := PlexTokenFromPrefs(prefsPath)
if token != "" { if token != "" {
t.Errorf("token = %q, want empty", token) t.Errorf("token = %q, want empty", token)
} }
}) })
t.Run("file not found", func(t *testing.T) { t.Run("file not found", func(t *testing.T) {
token := plexTokenFromPrefs("/nonexistent/Preferences.xml") token := PlexTokenFromPrefs("/nonexistent/Preferences.xml")
if token != "" { if token != "" {
t.Errorf("token = %q, want empty", token) t.Errorf("token = %q, want empty", token)
} }
@ -109,7 +109,7 @@ func TestPlexTokenFromPrefs(t *testing.T) {
prefsPath := filepath.Join(dir, "Preferences.xml") prefsPath := filepath.Join(dir, "Preferences.xml")
os.WriteFile(prefsPath, []byte("not xml at all"), 0o644) os.WriteFile(prefsPath, []byte("not xml at all"), 0o644)
token := plexTokenFromPrefs(prefsPath) token := PlexTokenFromPrefs(prefsPath)
if token != "" { if token != "" {
t.Errorf("token = %q, want empty", token) t.Errorf("token = %q, want empty", token)
} }

View file

@ -0,0 +1,280 @@
package mediaserver
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// ServerConfig describes a media server that should be refreshed after a
// download (or .strm write) finishes. Stored in unarr's config.toml under
// [[mediaserver]].
type ServerConfig struct {
Kind string `toml:"kind"` // "plex" | "jellyfin" | "emby"
URL string `toml:"url"` // e.g. http://localhost:32400
Token string `toml:"token"` // Plex token / Jellyfin or Emby API key
Sections []int `toml:"sections"` // optional: Plex section IDs to refresh (else auto)
}
// Section describes a Plex library section.
type Section struct {
ID int
Title string
Locations []string
}
// httpClient is the shared HTTP client for refresh calls. Short timeouts
// because a refresh trigger is fire-and-forget.
var httpClient = &http.Client{Timeout: 8 * time.Second}
// plexSectionCache caches section lookups per server URL+token, so we don't
// re-fetch sections on every download.
var (
plexSectionMu sync.RWMutex
plexSectionCache = map[string][]Section{}
)
// Refresh fans out a refresh call to every configured media server. Errors
// are logged but never returned — a failed refresh is non-fatal because the
// download itself succeeded and the next periodic scan will pick it up.
//
// `filePath` is the path of the file that was just placed (or the .strm
// pointer); it's used to resolve the matching Plex section for partial
// refreshes. Pass "" to fall back to a full refresh.
//
// Each refresh runs in its own goroutine with a 15s timeout — the call
// returns immediately and never blocks the caller.
func Refresh(servers []ServerConfig, filePath string) {
for _, s := range servers {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := refreshOne(ctx, s, filePath); err != nil {
log.Printf("mediaserver: %s refresh failed (%s): %v", s.Kind, s.URL, err)
}
}()
}
}
func refreshOne(ctx context.Context, s ServerConfig, filePath string) error {
switch strings.ToLower(s.Kind) {
case "plex":
return refreshPlex(ctx, s, filePath)
case "jellyfin", "emby":
return refreshJellyfin(ctx, s)
default:
return fmt.Errorf("unknown kind %q", s.Kind)
}
}
// ── Plex ────────────────────────────────────────────────────────────
func refreshPlex(ctx context.Context, s ServerConfig, filePath string) error {
if s.URL == "" || s.Token == "" {
return fmt.Errorf("plex: missing url or token")
}
sectionIDs := s.Sections
if len(sectionIDs) == 0 {
// Auto-resolve: fetch sections, pick whichever owns the file path.
sections, err := plexSections(ctx, s.URL, s.Token)
if err != nil {
return fmt.Errorf("fetch sections: %w", err)
}
if filePath != "" {
if id, ok := matchSectionByPath(sections, filePath); ok {
sectionIDs = []int{id}
}
}
if len(sectionIDs) == 0 {
// Fall back to refreshing every section.
for _, sec := range sections {
sectionIDs = append(sectionIDs, sec.ID)
}
}
}
var firstErr error
for _, id := range sectionIDs {
if err := plexRefreshSection(ctx, s.URL, s.Token, id, filePath); err != nil {
if firstErr == nil {
firstErr = err
}
log.Printf("mediaserver: plex section %d refresh failed: %v", id, err)
}
}
return firstErr
}
func plexRefreshSection(ctx context.Context, baseURL, token string, sectionID int, filePath string) error {
q := url.Values{}
if filePath != "" {
q.Set("path", filePath)
}
q.Set("X-Plex-Token", token)
endpoint := fmt.Sprintf("%s/library/sections/%d/refresh?%s",
strings.TrimRight(baseURL, "/"), sectionID, q.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d", resp.StatusCode)
}
// plexSections returns the cached or freshly fetched library sections for a
// Plex server. The cache lives for the agent's lifetime — Plex sections
// rarely change, so refetching on every download is wasteful.
func plexSections(ctx context.Context, baseURL, token string) ([]Section, error) {
key := baseURL + "|" + token
plexSectionMu.RLock()
cached, ok := plexSectionCache[key]
plexSectionMu.RUnlock()
if ok {
return cached, nil
}
sections, err := fetchPlexSections(ctx, baseURL, token)
if err != nil {
return nil, err
}
plexSectionMu.Lock()
plexSectionCache[key] = sections
plexSectionMu.Unlock()
return sections, nil
}
func fetchPlexSections(ctx context.Context, baseURL, token string) ([]Section, error) {
endpoint := strings.TrimRight(baseURL, "/") + "/library/sections"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Plex-Token", token)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, err
}
return parsePlexSectionsFull(body)
}
// parsePlexSectionsFull parses the full sections response with IDs.
func parsePlexSectionsFull(body []byte) ([]Section, error) {
var container struct {
MediaContainer struct {
Directory []struct {
Key string `json:"key"`
Title string `json:"title"`
Location []struct {
Path string `json:"path"`
} `json:"Location"`
} `json:"Directory"`
} `json:"MediaContainer"`
}
if err := json.Unmarshal(body, &container); err != nil {
return nil, err
}
var out []Section
for _, d := range container.MediaContainer.Directory {
var id int
_, _ = fmt.Sscanf(d.Key, "%d", &id)
if id == 0 {
continue
}
sec := Section{ID: id, Title: d.Title}
for _, loc := range d.Location {
if loc.Path != "" {
sec.Locations = append(sec.Locations, loc.Path)
}
}
out = append(out, sec)
}
return out, nil
}
// matchSectionByPath returns the ID of the section whose Locations contain
// (as prefix) the given file path. Picks the most specific (longest) match.
func matchSectionByPath(sections []Section, filePath string) (int, bool) {
bestID := 0
bestLen := 0
for _, s := range sections {
for _, loc := range s.Locations {
loc = strings.TrimRight(loc, "/")
if loc == "" {
continue
}
if filePath == loc || strings.HasPrefix(filePath, loc+"/") {
if len(loc) > bestLen {
bestLen = len(loc)
bestID = s.ID
}
}
}
}
return bestID, bestID != 0
}
// ── Jellyfin / Emby ─────────────────────────────────────────────────
func refreshJellyfin(ctx context.Context, s ServerConfig) error {
if s.URL == "" || s.Token == "" {
return fmt.Errorf("%s: missing url or token", s.Kind)
}
endpoint := strings.TrimRight(s.URL, "/") + "/Library/Refresh"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return err
}
// Both Jellyfin and Emby accept this header.
req.Header.Set("X-Emby-Token", s.Token)
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, resp.Body)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
return fmt.Errorf("status %d", resp.StatusCode)
}

View file

@ -0,0 +1,149 @@
package mediaserver
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestParsePlexSectionsFull(t *testing.T) {
body := []byte(`{
"MediaContainer": {
"Directory": [
{ "key": "1", "title": "Movies", "Location": [{"path": "/data/media/movies"}] },
{ "key": "2", "title": "TV Shows", "Location": [{"path": "/data/media/tv"}, {"path": "/mnt/tv2"}] },
{ "key": "0", "title": "Bogus", "Location": [{"path": "/skip"}] }
]
}
}`)
sections, err := parsePlexSectionsFull(body)
if err != nil {
t.Fatalf("parsePlexSectionsFull error: %v", err)
}
if len(sections) != 2 {
t.Fatalf("got %d sections, want 2 (id=0 should be skipped)", len(sections))
}
if sections[0].ID != 1 || sections[0].Title != "Movies" {
t.Errorf("section[0] = %+v", sections[0])
}
if sections[1].ID != 2 || len(sections[1].Locations) != 2 {
t.Errorf("section[1] = %+v", sections[1])
}
}
func TestMatchSectionByPath(t *testing.T) {
sections := []Section{
{ID: 1, Title: "Movies", Locations: []string{"/data/media/movies"}},
{ID: 2, Title: "TV", Locations: []string{"/data/media/tv"}},
{ID: 3, Title: "TV-HD", Locations: []string{"/data/media/tv/hd"}},
}
tests := []struct {
path string
wantID int
wantOK bool
}{
{"/data/media/movies/Inception (2010)/Inception.mkv", 1, true},
{"/data/media/tv/Show/Season 01/ep.mkv", 2, true},
{"/data/media/tv/hd/Show/Season 01/ep.mkv", 3, true}, // most specific wins
{"/data/media/movies", 1, true}, // exact
{"/elsewhere/foo.mkv", 0, false},
}
for _, tc := range tests {
gotID, gotOK := matchSectionByPath(sections, tc.path)
if gotID != tc.wantID || gotOK != tc.wantOK {
t.Errorf("matchSectionByPath(%q) = (%d,%v), want (%d,%v)",
tc.path, gotID, gotOK, tc.wantID, tc.wantOK)
}
}
}
func TestRefreshPlex_PartialRefreshWithPath(t *testing.T) {
var refreshHits int32
var gotPath, gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/library/sections":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"MediaContainer": {
"Directory": [
{ "key": "7", "title": "Movies", "Location": [{"path": "/m"}] }
]
}
}`))
case strings.HasPrefix(r.URL.Path, "/library/sections/7/refresh"):
atomic.AddInt32(&refreshHits, 1)
gotPath = r.URL.Query().Get("path")
gotToken = r.URL.Query().Get("X-Plex-Token")
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
// Reset cache so the test server is hit fresh.
plexSectionMu.Lock()
plexSectionCache = map[string][]Section{}
plexSectionMu.Unlock()
cfg := ServerConfig{Kind: "plex", URL: srv.URL, Token: "tk-1"}
Refresh([]ServerConfig{cfg}, "/m/Inception/Inception.mkv")
// Refresh fans out goroutines — give it time.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&refreshHits) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if atomic.LoadInt32(&refreshHits) != 1 {
t.Fatalf("refresh endpoint hit %d times, want 1", refreshHits)
}
if gotPath != "/m/Inception/Inception.mkv" {
t.Errorf("path query = %q", gotPath)
}
if gotToken != "tk-1" {
t.Errorf("token query = %q", gotToken)
}
}
func TestRefreshJellyfin(t *testing.T) {
var hits int32
var gotToken string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && r.URL.Path == "/Library/Refresh" {
atomic.AddInt32(&hits, 1)
gotToken = r.Header.Get("X-Emby-Token")
w.WriteHeader(http.StatusNoContent)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
cfg := ServerConfig{Kind: "jellyfin", URL: srv.URL, Token: "jf-key"}
Refresh([]ServerConfig{cfg}, "")
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&hits) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
if atomic.LoadInt32(&hits) != 1 {
t.Fatalf("Jellyfin hits = %d, want 1", hits)
}
if gotToken != "jf-key" {
t.Errorf("X-Emby-Token = %q", gotToken)
}
}

View file

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

View file

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

View file

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

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