diff --git a/.env.example b/.env.example
deleted file mode 100644
index 4091938..0000000
--- a/.env.example
+++ /dev/null
@@ -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
diff --git a/.forgejo/workflows/docker-rebuild.yml b/.forgejo/workflows/docker-rebuild.yml
deleted file mode 100644
index 34cc3d6..0000000
--- a/.forgejo/workflows/docker-rebuild.yml
+++ /dev/null
@@ -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 \
- .
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
deleted file mode 100644
index d757612..0000000
--- a/.forgejo/workflows/release.yml
+++ /dev/null
@@ -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 \
- .
diff --git a/.forgejo/workflows/ci.yml b/.github/workflows/ci.yml
similarity index 61%
rename from .forgejo/workflows/ci.yml
rename to .github/workflows/ci.yml
index 82ee799..7dabcc4 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,26 +12,35 @@ permissions:
jobs:
test:
name: Test
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ go-version: ["1.25"]
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
run: go test -v -race -count=1 ./...
build:
name: Build
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
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
env:
@@ -41,30 +50,30 @@ jobs:
lint:
name: Lint
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - name: Install golangci-lint
- run: |
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \
- | sh -s -- -b /usr/local/bin v2.11.4
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: "1.25"
- name: Run golangci-lint
- run: golangci-lint run ./...
+ uses: golangci/golangci-lint-action@v9
+ with:
+ version: v2.11.4
coverage:
name: Coverage
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - name: Install python3
- run: apt-get update && apt-get install -y --no-install-recommends python3
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: "1.25"
- name: Run tests with coverage (all packages)
run: |
@@ -93,13 +102,24 @@ jobs:
print('OK: Coverage meets minimum threshold')
"
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v6
+ with:
+ files: ./coverage.out
+ fail_ci_if_error: false
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
vet:
name: Vet
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
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
run: go vet ./...
diff --git a/.github/workflows/docker-rebuild.yml b/.github/workflows/docker-rebuild.yml
new file mode 100644
index 0000000..c1634f1
--- /dev/null
+++ b/.github/workflows/docker-rebuild.yml
@@ -0,0 +1,52 @@
+# 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: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ # Stamp the binary with the most recent release tag (not "dev").
+ - name: Resolve version
+ id: ver
+ run: echo "version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" >> "$GITHUB_OUTPUT"
+
+ - 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
+ # Refresh the floating tag only — never overwrite a versioned release.
+ tags: torrentclaw/unarr:latest
+ build-args: |
+ VERSION=${{ steps.ver.outputs.version }}
+ # Force a fresh base pull so apk upgrade picks up new patches.
+ no-cache: true
+
+ - name: Scan image for fixable CVEs (gate)
+ uses: docker/scout-action@v1
+ with:
+ command: cves
+ image: torrentclaw/unarr:latest
+ only-severities: critical,high
+ only-fixed: true
+ exit-code: true
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..d0c683d
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,52 @@
+name: Deploy install scripts to Pages
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - install.sh
+ - install.ps1
+ - CNAME
+ - .nojekyll
+ - .github/workflows/pages.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - name: Stage install scripts
+ run: |
+ mkdir -p _site
+ cp install.sh install.ps1 _site/
+ [ -f CNAME ] && cp CNAME _site/
+ touch _site/.nojekyll
+ # Also index page (humans landing)
+ cat > _site/index.html <<'HTML'
+
+
unarr installer
+ unarr CLI installer
+ Linux/macOS: curl -fsSL https://unarr.torrentclaw.com/install.sh | sh
+ Windows: irm https://unarr.torrentclaw.com/install.ps1 | iex
+ Source: github.com/torrentclaw/unarr
+
+ HTML
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..dcb49ce
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,210 @@
+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 }}
+ # 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 }}
+
+ - name: Sign checksums.txt with ed25519
+ # Reference secrets.X directly — step-level env defined in this same
+ # step is unreliable to read from this step's own if: expression.
+ if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
+ env:
+ RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ go run ./scripts/sign-checksums \
+ -key "$RELEASE_SIGNING_KEY" \
+ -in dist/checksums.txt \
+ -out dist/checksums.txt.sig
+ gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber
+
+ 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 }}
+
+ # CVE gate. Fails the release on FIXABLE critical/high only — unfixed
+ # upstream ffmpeg codec CVEs are accepted (see SECURITY.md), so the
+ # codec noise does not block. Runs post-push (image already published);
+ # a failure here flags that a fixable CVE slipped through.
+ - name: Scan image for fixable CVEs (gate)
+ uses: docker/scout-action@v1
+ with:
+ command: cves
+ image: torrentclaw/unarr:latest
+ only-severities: critical,high
+ only-fixed: true
+ exit-code: true
+
+ # Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a
+ # description-API auth hiccup must not undo a successful image push.
+ - name: Update Docker Hub description
+ uses: peter-evans/dockerhub-description@v4
+ continue-on-error: true
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ repository: torrentclaw/unarr
+ readme-filepath: ./DOCKERHUB.md
+ short-description: "unarr — the single binary that replaces your *arr stack"
+
+
+ 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"
diff --git a/.gitignore b/.gitignore
index 8015bab..81f1284 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,7 +41,4 @@ dist-ffbinaries/
# Docker
tmp/
config/
-dist-ffbinaries/
-
-# Claude Code: keep entirely local, do not track
-.claude/
\ No newline at end of file
+dist-ffbinaries/
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 6bc4a51..26ce802 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -59,22 +59,6 @@ changelog:
- "^test:"
- "^chore:"
-# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN +
-# these URLs and publishes the release there instead of GitHub. Reachable via
-# `forgejo` hostname inside the dokploy-network (the runner shares it); for
-# local goreleaser runs outside the network, override via env GITEA_API_URL.
-#
-# In goreleaser v2 `gitea_urls` is a top-level key (was nested under `release`
-# in v1).
-gitea_urls:
- api: http://forgejo:3000/api/v1
- download: https://git.torrentclaw.com
- skip_tls_verify: false
-
-release:
- draft: false
- prerelease: auto
-
# Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
# brews:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fa0d872..7a2366f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,162 +5,158 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [0.9.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)
+- **auto-upgrade restart loop**: when the server signal arrived for a version
+ the daemon was already running (e.g. flag still set after a previous
+ upgrade), `applyAutoUpgrade` would call `upgrade.Execute` (which no-ops),
+ then `os.Exit(0)` anyway — systemd respawned, the flag was still set, the
+ cycle repeated. Now: no-op case is detected up front, the daemon clears
+ the server flag via `/api/internal/agent/upgrade-result` and stays alive.
+- **upgrade flag stuck after success**: the CLI never reported the upgrade
+ outcome, so `upgrade_requested` stayed `true` in the DB forever. The
+ daemon now calls `/api/internal/agent/upgrade-result` on every applyAutoUpgrade
+ branch (success, failure, no-op) — server clears the flag, restart loops
+ end.
+
+### Added
+
+- New `Client.ReportUpgradeResult(agentID, success, version, error)` HTTP
+ method wrapping `POST /api/internal/agent/upgrade-result`.
+
## [0.9.7] - 2026-05-26
-
### Added
-- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
+- **hls cache**: persistent fMP4 segment cache keyed by
+ `(source, quality, audio_index)`. After a successful encode the segments
+ + `init.mp4` are kept under `~/.cache/unarr/hls-cache/{key}/` with a
+ `.complete` marker. A second play of the same file at the same quality
+ skips ffmpeg entirely (smoke-tested 23–31× faster than re-encode). LRU
+ + size-budget eviction; pinned during active play; per-key writer-lock
+ prevents two concurrent encodes from corrupting each other. Startup
+ reaps orphan dirs without `.complete` older than 10 min so a daemon
+ crash doesn't leak disk indefinitely. New `[downloads.hls_cache]` block
+ in `config.toml`: `enabled` (default true), `size_gb` (default 5,
+ min 1), `dir` (default `~/.cache/unarr/hls-cache`).
+- **hls cache integrity check**: on HIT, the daemon stats `init.mp4` +
+ last segment before reporting cache reuse — if a file was externally
+ deleted, the entry is invalidated and re-encoded transparently.
+- **hls cache stats**: hit/miss counters surface via `cache.Stats()`
+ (`Hits`, `Misses`, `EntryCount`, `TotalBytes`) and the sweeper logs a
+ daily summary line `[hls_cache] day-stats: hits=N misses=M ratio=X%
+ entries=Y size=ZMB`.
+- **subtitle integrity for cached replay**: `Close` waits up to 15 s for
+ the subtitle extractor goroutine before sealing `.complete` so a HIT
+ never serves half-written `.vtt` files. Timeout invalidates instead of
+ sealing.
+
+### Changed
+
+- `[daemon] auto_upgrade` now appears in fresh `config.toml` files as
+ `true` (it was always the implicit default; this just makes it visible
+ in default-generated configs).
+
## [0.9.6] - 2026-05-26
-
### Added
-- **daemon**: auto-apply upgrades when server signals (0.9.6)
+- **auto-upgrade**: when the web flags the agent for upgrade
+ (`POST /api/internal/agent/upgrade` or the "Force update now" button),
+ the daemon now downloads and replaces the binary in-place, then exits so
+ the service supervisor (`systemd Restart=always` on Linux, the equivalent
+ on macOS/Windows) respawns on the new version. No `unarr update` step
+ required from the user. Still opt-in — only fires when the server sends
+ the upgrade signal.
+
+### Changed
+
+- The `OnUpgrade` daemon callback no longer just logs `run unarr self-update`;
+ it now triggers the actual upgrade in a background goroutine.
+
## [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
+- **funnel**: optional CloudFlare Quick Tunnel subprocess. `unarr funnel on`
+ spawns `cloudflared` as a child process and registers an anonymous
+ `https://.trycloudflare.com` hostname tunnelled to the daemon's
+ HLS server. The hostname is reported back to the web on every sync so the
+ in-browser player picks it up automatically — cross-network playback now
+ works on torrentclaw.com without Tailscale or port forwarding. Bytes
+ proxy through CloudFlare; TorrentClaw still doesn't relay content.
+- **funnel**: on by default for fresh installs (NAS/Docker get cross-network
+ HTTPS automatically); existing configs that pre-date the feature stay
+ off until the operator runs `unarr funnel on`.
+- **funnel**: auto-downloads cloudflared to the unarr data dir when not on
+ PATH (Linux amd64/arm64/armhf/386). ELF magic + size sanity check on the
+ download; `O_EXCL` partial-write so concurrent daemons don't clobber
+ each other.
+- **funnel**: subprocess supervisor keeps the tunnel up across cloudflared
+ crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min)
+ on persistent failures. The web's reported URL is cleared the moment
+ cloudflared exits so an outdated hostname doesn't keep handing out 502s.
+- **funnel**: `unarr funnel status` shows the live URL once registered.
+ See README §`[downloads.funnel]` for the throughput / latency caveats of
+ CF's free Quick Tunnels.
+- **docker**: the official `torrentclaw/unarr` image now bundles
+ `cloudflared` so the funnel works the moment the container starts — no
+ first-run download.
### Fixed
-- **engine**: truncate errorMessage before reporting status
-- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
-## [0.9.2] - 2026-05-22
+- **hls/libx264**: bump the H.264 level we hint to libx264 by one tier so
+ anamorphic (>16:9) sources stop emitting unplayable streams. 720p at
+ level 3.1 silently rejected 1728×720 cinemascope frames with
+ `frame MB size > level limit`; 720p now ships at level 4.0, 1080p at 4.1.
+ Decoder compatibility is unaffected — every device that handles 1080p
+ already handles ≥ 4.1.
+## [0.9.4] - 2026-05-26
+
+### Removed
+
+- **streaming**: retire the custom WebRTC DataChannel pipeline. The daemon no
+ longer ships pion/webrtc, the WSS signaling client, or the wire framing
+ package — every in-browser session now uses HLS over HTTP from the daemon
+ (Tailscale / LAN / UPnP). Browser P2P (WebTorrent) bytes never re-enabled.
+- **config**: `[downloads.webrtc]` block removed from the TOML schema; existing
+ config files with the section parse cleanly because go-toml ignores unknown
+ sections.
+- **seed_file**: `mode=seed_file` task handler + `engine.SeedFile` helper
+ dropped — the last in-browser caller was retired with the WebRTC player.
+- **wstracker-probe**: standalone probe binary removed.
+
+### Changed
+
+- **agent wire**: `SyncResponse.WebRTCSessions` (JSON: `webrtcSessions`) renamed
+ to `StreamSessions` (JSON: `streamSessions`). The Go type `agent.WebRTCSession`
+ is now `agent.StreamSession`. Wire-incompatible with web < 2026-05-26.
+- **torrent**: `buildMagnet` no longer accepts an `extraTrackers` variadic —
+ the default tracker list is the only set used.
+
+### Fixed
+
+- **hls**: clamp the ffmpeg `-b:v` to the bitrate cap derived from the EFFECTIVE
+ output height instead of the requested quality. Previously asking for "2160p"
+ on a 1080p source overshot the H.264 level we resolved from the effective
+ height (4.0, max 20 Mbps) and made libx264 abort with
+ `VBV bitrate > level limit`.
+
+## [0.9.2] - 2026-05-21
### Added
-- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
+- **vpn**: `unarr vpn` command (`status`, `enable`, `disable`) to manage the managed
+ WireGuard split-tunnel, with `vpn status --check` to verify provisioning.
+- **vpn**: report split-tunnel state (active, exit server) to the web on register
+ + every sync, so the dashboard shows which agent holds the single WireGuard slot.
+- **vpn**: send the agent id when fetching the VPN config so the web can arbitrate
+ the single WireGuard slot — the first agent claims it; the rest are told to run
+ OpenVPN on their own host (1 agent on WireGuard + up to 9 on OpenVPN).
+
## [0.9.1] - 2026-05-21
@@ -171,10 +167,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **security**: bump golang.org/x deps and add container CVE scan gate
-
-### Other
-
-- **release**: 0.9.1
## [0.9.0] - 2026-05-21
@@ -184,10 +176,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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
@@ -201,8 +189,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
@@ -576,20 +562,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Build
- 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
diff --git a/DOCKERHUB.md b/DOCKERHUB.md
index 3df5b70..7a9bc0e 100644
--- a/DOCKERHUB.md
+++ b/DOCKERHUB.md
@@ -1,9 +1,8 @@
# unarr
-**The single binary that replaces your whole *arr stack.** Built-in torrent,
-debrid, and usenet engines. Stream, transcode, and organize your library from
-one terminal — or run it as a headless daemon with a web dashboard, WireGuard
-split-tunnel, and Cloudflare Funnel remote access.
+**The single binary that replaces your whole *arr stack.** Search 30+ torrent
+sources, inspect real quality before you download, grab subtitles, and manage
+your media library — all from one terminal tool or a headless daemon.
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
diff --git a/Makefile b/Makefile
index b3325bc..08462b6 100644
--- a/Makefile
+++ b/Makefile
@@ -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
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; }
@./scripts/release.sh --dry-run $(V)
-## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions.
-## Reads version from internal/cmd/version.go unless V= is provided.
-ship:
- @./scripts/ship.sh $(V)
-
-## Ship + git push tag to GH afterwards
-ship-push:
- @./scripts/ship.sh --push $(V)
-
-## Preview ship steps without executing
-ship-dry:
- @./scripts/ship.sh --dry-run $(V)
-
## Remove generated files
clean:
rm -f $(BINARY) coverage.out coverage.html
diff --git a/README.md b/README.md
index 75c9c62..8a5d26d 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,9 @@
[](LICENSE)
[](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.
diff --git a/docker-compose.yml b/docker-compose.yml
index 8e0b32e..5f49fcf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
unarr:
+ build:
+ context: ..
+ dockerfile: unarr/Dockerfile
image: torrentclaw/unarr:latest
- pull_policy: always # always pull on `up` so you stay on the latest release
container_name: unarr
restart: unless-stopped
+ user: "1000:1000"
- # host network is required for:
- # - streaming to reach your TV / mobile / other LAN devices (port 11818)
- # - HLS transcode server (port 11819)
- # - Tailscale connectivity (if you use it)
- # 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}
+ # Read-only root filesystem — only volumes are writable
+ read_only: true
+ tmpfs:
+ - /tmp:size=64m,mode=1777
volumes:
- # Config: config.toml is auto-created here on first run.
- # After first start, edit this file to set organize paths, quality, etc.
- - ${CONFIG_DIR:-./config}:/config
-
- # Downloads: where finished media is saved.
- # 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).
+ # Config: your config.toml lives here
+ - ./config:/config
+ # Downloads: finished media goes here
+ - ~/Media:/downloads
+ # Data: torrent metadata, piece DB, cache
- unarr-data:/data
- # Optional: limit CPU/RAM for transcoding on shared hosts
- # deploy:
- # resources:
- # limits:
- # memory: 2G
- # cpus: "4.0"
+ environment:
+ - TZ=${TZ:-UTC}
+ # Optional overrides (uncomment to use):
+ # - UNARR_API_KEY=tc_your_key_here
+ # - UNARR_API_URL=https://torrentclaw.com
- # --- macOS / Windows alternative (replace network_mode: host above) ---
- # network_mode: bridge
+ # Resource limits — adjust to your needs
+ 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:
- # - "11818:11818" # direct stream (VLC, download)
- # - "11819:11819" # HLS transcode (web player)
- # - "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.
+ # - "6881-6889:6881-6889/tcp"
+ # - "6881-6889:6881-6889/udp"
volumes:
unarr-data:
diff --git a/internal/agent/client.go b/internal/agent/client.go
index e7f2c37..e60b0a4 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -109,27 +109,6 @@ func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, succes
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//master.m3u8.
-//
-// Best-effort: the server is the source of truth for session state and
-// will reach the same conclusion via HEAD probes anyway if this call
-// fails. We log the error in the caller but don't retry — by the time
-// a retry would land the user is likely already playing.
-func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error {
- req := struct {
- SessionID string `json:"sessionId"`
- }{SessionID: sessionID}
- var resp StatusResponse
- if err := c.doPost(ctx, "/api/internal/agent/session-ready", req, &resp); err != nil {
- return fmt.Errorf("mark session ready: %w", err)
- }
- return nil
-}
-
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse
diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go
index f7994fb..68a187f 100644
--- a/internal/agent/daemon.go
+++ b/internal/agent/daemon.go
@@ -28,15 +28,7 @@ type DaemonConfig struct {
ScanPaths []string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
- // Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon
- // start. Surfaced in the web "Diagnose transcoder" modal — lets a user
- // see which encoders the ffmpeg binary supports and which devices the
- // host exposes without running `unarr probe-hwaccel`.
- FFmpegVersion string // first line of `ffmpeg -version`
- FFmpegPath string // resolved binary path
- HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders`
- HWDevices []string // device files + driver bins detected at probe time
- AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
+ AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
}
// Daemon manages agent registration and the sync loop.
@@ -130,10 +122,6 @@ func (d *Daemon) Register(ctx context.Context) error {
TailscaleIP: d.cfg.TailscaleIP,
HWAccel: d.cfg.HWAccel,
MaxTranscodeHeight: d.cfg.MaxTranscodeHeight,
- FFmpegVersion: d.cfg.FFmpegVersion,
- FFmpegPath: d.cfg.FFmpegPath,
- HWEncoders: d.cfg.HWEncoders,
- HWDevices: d.cfg.HWDevices,
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
diff --git a/internal/agent/state.go b/internal/agent/state.go
index cc08ae5..1f00033 100644
--- a/internal/agent/state.go
+++ b/internal/agent/state.go
@@ -2,8 +2,6 @@ package agent
import (
"encoding/json"
- "errors"
- "fmt"
"os"
"path/filepath"
"time"
@@ -11,13 +9,6 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
-// ErrDaemonNotRunning is returned when no daemon state file exists on disk.
-// Callers may wrap it with %w; downstream code uses errors.Is to detect it.
-// NOTE: the message text is matched by the sentry package (string-match, to
-// avoid an import cycle). Keep the prefix "daemon does not appear to be
-// running" stable, or update sentry.daemonNotRunningMarker accordingly.
-var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
-
// DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct {
AgentID string `json:"agentId"`
@@ -78,31 +69,17 @@ func WriteState(state *DaemonState) {
os.Rename(tmp, path)
}
-// ReadState reads the daemon state from disk. Returns nil if not found or
-// unreadable. Use LoadState when callers need to distinguish "not running"
-// from "state file corrupted".
+// ReadState reads the daemon state from disk. Returns nil if not found.
func ReadState() *DaemonState {
- state, _ := LoadState()
- return state
-}
-
-// LoadState reads the daemon state and returns explicit errors:
-// - ErrDaemonNotRunning when the state file does not exist
-// - a wrapped json error when the file exists but cannot be decoded
-// (a real bug worth reporting to Sentry)
-func LoadState() (*DaemonState, error) {
data, err := os.ReadFile(StateFilePath())
if err != nil {
- if errors.Is(err, os.ErrNotExist) {
- return nil, ErrDaemonNotRunning
- }
- return nil, err
+ return nil
}
var state DaemonState
- if err := json.Unmarshal(data, &state); err != nil {
- return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
+ if json.Unmarshal(data, &state) != nil {
+ return nil
}
- return &state, nil
+ return &state
}
// RemoveState deletes the state file (called on clean shutdown).
diff --git a/internal/agent/state_test.go b/internal/agent/state_test.go
index 7e275be..6c9abdd 100644
--- a/internal/agent/state_test.go
+++ b/internal/agent/state_test.go
@@ -1,7 +1,6 @@
package agent
import (
- "errors"
"os"
"path/filepath"
"testing"
@@ -105,39 +104,3 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
}
}
-
-func TestLoadStateNotFound(t *testing.T) {
- tmpDir := t.TempDir()
- origFn := stateFilePathFn
- stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") }
- defer func() { stateFilePathFn = origFn }()
-
- state, err := LoadState()
- if state != nil {
- t.Errorf("LoadState() state = %+v, want nil", state)
- }
- if !errors.Is(err, ErrDaemonNotRunning) {
- t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err)
- }
-}
-
-func TestLoadStateCorruptedJSON(t *testing.T) {
- tmpDir := t.TempDir()
- origFn := stateFilePathFn
- path := filepath.Join(tmpDir, "daemon.state.json")
- stateFilePathFn = func() string { return path }
- defer func() { stateFilePathFn = origFn }()
-
- os.WriteFile(path, []byte("not valid json{{{"), 0o644)
-
- state, err := LoadState()
- if state != nil {
- t.Errorf("LoadState() state = %+v, want nil", state)
- }
- if err == nil {
- t.Fatal("LoadState() err = nil, want decode error")
- }
- if errors.Is(err, ErrDaemonNotRunning) {
- t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry")
- }
-}
diff --git a/internal/agent/types.go b/internal/agent/types.go
index ae87bb6..00802bc 100644
--- a/internal/agent/types.go
+++ b/internal/agent/types.go
@@ -26,15 +26,6 @@ type RegisterRequest struct {
// up to 2160p.
HWAccel string `json:"hwAccel,omitempty"`
MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"`
- // Diagnostic surface filled by engine.DetectHWAccelDiagnostic at daemon
- // start. Surfaced in the web "Diagnose transcoder" modal so users can
- // see *why* their HWAccel landed on "none" without running
- // `unarr probe-hwaccel` locally — most commonly the ffmpeg binary
- // shipped without HW encoders (linuxbrew, brew's default formula).
- FFmpegVersion string `json:"ffmpegVersion,omitempty"`
- FFmpegPath string `json:"ffmpegPath,omitempty"`
- HWEncoders []string `json:"hwEncoders,omitempty"`
- HWDevices []string `json:"hwDevices,omitempty"`
// Managed-VPN split-tunnel state. The web tracks which agent holds the single
// WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent
// connection); other agents are told to use OpenVPN on their host instead.
diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go
index 425cee0..19c4b7c 100644
--- a/internal/cmd/daemon.go
+++ b/internal/cmd/daemon.go
@@ -143,19 +143,7 @@ func runDaemonStart() error {
// is what the web side uses to decide whether the user should pre-empt
// transcoding by downloading a smaller version (4K source on a software
// libx264-only host is the canonical case where pre-download wins).
- //
- // 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
+ hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath)
maxTranscodeHeight := 1080
if hwAccelPick != engine.HWAccelNone {
maxTranscodeHeight = 2160
@@ -174,10 +162,6 @@ func runDaemonStart() error {
ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath),
HWAccel: string(hwAccelPick),
MaxTranscodeHeight: maxTranscodeHeight,
- FFmpegVersion: hwDiag.FFmpegVersion,
- FFmpegPath: hwDiag.FFmpegPath,
- HWEncoders: hwDiag.Encoders,
- HWDevices: hwDiag.Devices,
AutoUpgrade: cfg.Daemon.AutoUpgradeEnabled(),
}
@@ -265,16 +249,15 @@ func runDaemonStart() error {
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
- DataDir: cfg.Download.Dir,
- PieceCompletionDir: config.DataDir(), // keep piece-completion DB off NFS/SMB mounts
- MetadataTimeout: metaTimeout,
- StallTimeout: stallTimeout,
- MaxTimeout: 0,
- MaxDownloadRate: maxDl,
- MaxUploadRate: maxUl,
- ListenPort: cfg.Download.ListenPort,
- SeedEnabled: false,
- VPNTunnel: vpnTunnel,
+ DataDir: cfg.Download.Dir,
+ MetadataTimeout: metaTimeout,
+ StallTimeout: stallTimeout,
+ MaxTimeout: 0,
+ MaxDownloadRate: maxDl,
+ MaxUploadRate: maxUl,
+ ListenPort: cfg.Download.ListenPort,
+ SeedEnabled: false,
+ VPNTunnel: vpnTunnel,
})
if err != nil {
return fmt.Errorf("create torrent downloader: %w", err)
@@ -310,15 +293,7 @@ func runDaemonStart() error {
// Create persistent stream server
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
- // CORS extras = operator config + dynamic mirror list from /api/mirrors.
- // Without the mirror merge, a user playing from `torrentclaw.to` (or any
- // future mirror) hits the daemon, gets 200 + body, but no
- // `Access-Control-Allow-Origin` → browser drops the response → player
- // reports "404 todos los canales". Fetching /api/mirrors at startup
- // future-proofs against mirror additions without a CLI rebuild.
- corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...)
- corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...)
- streamSrv.SetCORSAllowedOrigins(corsExtras)
+ streamSrv.SetCORSAllowedOrigins(cfg.Download.CORSExtraOrigins)
// 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.
@@ -597,28 +572,14 @@ func runDaemonStart() error {
Transcode: tcRuntime,
Cache: hlsCache,
}
- // StartHLSSession runs ffprobe (15 s cap, typical 0.3–1 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)
- }()
+ 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)
}
// Periodic DHT node persistence (every 5 min)
@@ -901,93 +862,3 @@ func superviseFunnel(ctx context.Context, d *agent.Daemon, port int) {
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
- }
- }
-}
diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go
index 4ac4d10..558fb26 100644
--- a/internal/cmd/daemon_control.go
+++ b/internal/cmd/daemon_control.go
@@ -1,7 +1,6 @@
package cmd
import (
- "errors"
"fmt"
"os"
"os/exec"
@@ -263,12 +262,9 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
- state, err := agent.LoadState()
- if err != nil {
- if errors.Is(err, agent.ErrDaemonNotRunning) {
- return err
- }
- return fmt.Errorf("read daemon state: %w", err)
+ state := agent.ReadState()
+ if state == nil {
+ return fmt.Errorf("daemon does not appear to be running (state file not found)")
}
return killPID(state.PID)
}
diff --git a/internal/cmd/download.go b/internal/cmd/download.go
index 5bf31a5..bd5ceab 100644
--- a/internal/cmd/download.go
+++ b/internal/cmd/download.go
@@ -119,10 +119,11 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
return fmt.Errorf("create downloader: %w", err)
}
- // Local-only reporter: one-shot downloads have no server-side task, so a nil
- // client keeps terminal progress working without spamming the status API
- // (which 400s the synthetic "oneshot-" id).
- reporter := engine.NewProgressReporter(nil, 5*time.Second)
+ // Create a dummy reporter (no API reporting for one-shot)
+ reporter := engine.NewProgressReporter(
+ deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
+ 5*time.Second,
+ )
debridDl := deps.newDebridDl()
diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go
index 34d8e4d..056112f 100644
--- a/internal/cmd/reload_unix.go
+++ b/internal/cmd/reload_unix.go
@@ -3,7 +3,6 @@
package cmd
import (
- "errors"
"fmt"
"log"
"os"
@@ -44,12 +43,9 @@ func startReloadWatcher(rc *ReloadableConfig) {
// sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error {
- state, err := agent.LoadState()
- if err != nil {
- if errors.Is(err, agent.ErrDaemonNotRunning) {
- return err
- }
- return fmt.Errorf("read daemon state: %w", err)
+ state := agent.ReadState()
+ if state == nil {
+ return fmt.Errorf("daemon does not appear to be running (state file not found)")
}
p, err := os.FindProcess(state.PID)
if err != nil {
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 375d8e9..b28ec92 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -25,20 +25,16 @@ var (
func init() {
rootCmd = &cobra.Command{
- Use: "unarr",
- Version: Version,
- Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
- Long: `unarr is a terminal-native client that downloads torrents, debrid links,
-and usenet (NZB) — all from the same binary. It streams content straight
-to mpv/vlc with sequential piece prioritization, transcodes on the fly via
-ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
-organizes your library into Movies/TV folders. Run it one-shot or as a
-long-running daemon with a built-in WireGuard split-tunnel and remote
-playback over Cloudflare Funnel.
+ Use: "unarr",
+ Short: "unarr — torrent search and management",
+ Long: `unarr is a powerful terminal tool for torrent search and management.
+
+Search 30+ torrent sources, inspect torrent quality, discover popular content,
+find streaming providers, and manage your media collection — all from your terminal.
Get started:
unarr init First-time configuration wizard
- unarr download Grab a torrent one-shot
+ unarr search "breaking bad" Search for content
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
@@ -59,7 +55,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
- &cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
+ &cobra.Group{ID: "search", Title: "Search & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go
index d05ae29..df66a18 100644
--- a/internal/cmd/scan.go
+++ b/internal/cmd/scan.go
@@ -241,7 +241,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue
}
- res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
+ res := library.ResolveResolution(item.MediaInfo.Video.Height)
if res == "" {
res = "other"
}
diff --git a/internal/cmd/version.go b/internal/cmd/version.go
index 8551bb1..379f923 100644
--- a/internal/cmd/version.go
+++ b/internal/cmd/version.go
@@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
-var Version = "0.9.19"
+var Version = "0.9.8"
diff --git a/internal/config/config.go b/internal/config/config.go
index dd406a6..dfa5e8a 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -96,27 +96,9 @@ type VPNConfig struct {
// Disabled by default; enabling requires ffmpeg + ffprobe on PATH (or
// explicit paths via the library config).
type TranscodeConfig struct {
- Enabled bool `toml:"enabled"` // master switch
- HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
- // Preset 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"`
+ Enabled bool `toml:"enabled"` // master switch
+ HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
+ Preset string `toml:"preset"` // libx264 preset; "veryfast" by default
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)
@@ -194,10 +176,7 @@ func Default() Config {
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: "",
+ Preset: "veryfast",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
@@ -301,12 +280,7 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
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 = ""
+ cfg.Download.Transcode.Preset = "veryfast"
}
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
cfg.Download.Transcode.AudioBitrate = "192k"
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
index c43599f..8097395 100644
--- a/internal/config/config_test.go
+++ b/internal/config/config_test.go
@@ -215,11 +215,8 @@ name = "Test"
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.Preset != "veryfast" {
+ t.Errorf("Transcode.Preset = %q, want veryfast", cfg.Download.Transcode.Preset)
}
if cfg.Download.Transcode.MaxConcurrent != 2 {
t.Errorf("Transcode.MaxConcurrent = %d, want 2", cfg.Download.Transcode.MaxConcurrent)
diff --git a/internal/engine/hls.go b/internal/engine/hls.go
index 8e0868a..795e893 100644
--- a/internal/engine/hls.go
+++ b/internal/engine/hls.go
@@ -32,46 +32,10 @@ import (
"time"
)
-// hlsSegmentDuration is the target seconds per HLS fragment.
-//
-// We use 2 seconds (not the more common 4-6 s). Trade-off: 2× more segments
-// per source (a 2 h movie produces 3600 segments instead of 1800), but the
-// player's first-frame wait drops to ~half — ffmpeg only needs to encode
-// 2 s before seg-0 lands. For software encodes on 4K this is ~1 s instead
-// of ~3 s of cold-cache wait. Well within HLS spec (Apple recommends 6 s,
-// but 2-6 s is acceptable; Low-Latency HLS uses 1-2 s segments).
-//
-// Caveat for existing cached encodes: cache entries from 0.9.9 used 4 s
-// segments. After this bump, VerifyComplete (which checks the highest
-// expected segment index) returns false for those entries — they're
-// invalidated + re-encoded with 2 s segments on next play. Self-healing.
-const hlsSegmentDuration = 2
-
-// segmentDurationFor returns the target duration (in whole seconds) for the
-// segment at index idx. With uniform-duration segments this is always
-// hlsSegmentDuration; the helper exists so a future short-first-segment
-// variant can be slotted in here without touching every call site.
-func segmentDurationFor(idx int) int {
- return hlsSegmentDuration
-}
-
-// segmentStartSec returns the wall-clock start time of segment idx. Used
-// to compute the `-ss` flag when ffmpeg restarts at a mid-file segment.
-func segmentStartSec(idx int) float64 {
- if idx <= 0 {
- return 0
- }
- return float64(idx * hlsSegmentDuration)
-}
-
-// segmentCountForDuration returns how many segments cover a source of the
-// given duration. Always returns at least 1.
-func segmentCountForDuration(dur float64) int {
- if dur <= 0 {
- return 1
- }
- return int((dur + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
-}
+// hlsSegmentDuration is the target seconds per HLS fragment. Four seconds is
+// the Plex/Apple default — short enough that seek granularity is acceptable,
+// long enough that GOP overhead doesn't dominate.
+const hlsSegmentDuration = 4
// hlsSessionTTL is how long a session can sit idle (no segment requests)
// before the manager kills ffmpeg + cleans the tmpdir.
@@ -172,13 +136,11 @@ type HLSSession struct {
restartCount int // bounded auto-restart counter (resets on Close)
lastRestartAt time.Time
- // readyCh + readyMax track how many segments ffmpeg has finished writing.
- // readyMax is a COUNT (not an index): readyMax=N means seg-0 … seg-(N-1)
- // are fully on disk. A handler waiting on `idx` blocks until
- // `idx < readyMax` (segment idx is present). The pollSegments goroutine
- // advances readyMax and re-creates readyCh on every step.
+ // readyCond + readyMax track which segments ffmpeg has finished writing.
+ // Handlers waiting on a future segment block on readyCond until the
+ // poller advances readyMax past their index (or ffmpeg exits).
readyMu sync.Mutex
- readyMax int
+ readyMax int // highest segment index whose .m4s file is fully written
exitErr error
exited bool
readyCh chan struct{} // closed + replaced each time readyMax advances
@@ -338,7 +300,10 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
// the last segment vanished (external rm, partial-disk failure), we
// can't actually serve a HIT — drop the dir and re-encode.
- segCountForVerify := segmentCountForDuration(probe.DurationSec)
+ segCountForVerify := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
+ if segCountForVerify < 1 {
+ segCountForVerify = 1
+ }
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
shortHLSID(cfg.SessionID), cacheKey)
@@ -390,7 +355,10 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
return nil, fmt.Errorf("hls: mkdir subs: %w", err)
}
- segCount := segmentCountForDuration(probe.DurationSec)
+ segCount := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
+ if segCount < 1 {
+ segCount = 1
+ }
s := &HLSSession{
cfg: cfg,
@@ -454,19 +422,9 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
if cfg.Cache != nil {
cachedNote = fmt.Sprintf(" (cache-miss %s)", cacheKey)
}
- // Surface the encoder profile so a "first-start was slow" report can be
- // triaged from the agent log alone — `encoder=libx264 accel=none` means
- // the user's ffmpeg has no HW encoders compiled in, which is the most
- // common root cause (linuxbrew, default brew formula on macOS).
- profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
- presetNote := ""
- if profile.Preset != "" {
- presetNote = " preset=" + profile.Preset
- }
- log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, encoder=%s accel=%s%s)%s",
+ log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s)%s",
shortHLSID(cfg.SessionID), filepath.Base(cfg.SourcePath),
- probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
- profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote)
+ probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"), cachedNote)
return s, nil
}
@@ -519,28 +477,6 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
}
-// ReadyCount returns how many segments are currently fully on disk.
-// Caller can `>= 1` it to check whether seg-0 has landed (and so the
-// player can be told to attach). For cache-HIT sessions this is always
-// `segmentCount` from the moment StartHLSSession returns.
-func (s *HLSSession) ReadyCount() int {
- s.readyMu.Lock()
- defer s.readyMu.Unlock()
- return s.readyMax
-}
-
-// FromCache reports whether this session was served from the HLS cache
-// (no ffmpeg subprocess spawned). Used by ready-watcher logic to short-
-// circuit polling — a cache HIT is ready the moment we return.
-func (s *HLSSession) FromCache() bool { return s.fromCache }
-
-// IsClosed reports whether Close() has been invoked. Exposed (vs the
-// internal isClosed) so external watchers — the ready-webhook
-// goroutine in cmd/daemon.go — can short-circuit polling on a session
-// that was torn down through a different code path (registry replace,
-// idle sweep) without racing on the unexported helper.
-func (s *HLSSession) IsClosed() bool { return s.isClosed() }
-
// MasterPlaylist returns the rendered master.m3u8 contents.
func (s *HLSSession) MasterPlaylist() string { return s.manifestRoot }
@@ -963,10 +899,8 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
time.Sleep(50 * time.Millisecond)
}
- // Build args for the new ffmpeg with -ss offset. Segments are non-uniform
- // (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
- // so use segmentStartSec for the seek time instead of multiplying.
- startSec := segmentStartSec(targetIdx)
+ // Build args for the new ffmpeg with -ss offset.
+ startSec := float64(targetIdx * hlsSegmentDuration)
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
ffCtx, cancel := context.WithCancel(context.Background())
@@ -1031,77 +965,23 @@ func buildHLSFFmpegArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string)
return buildHLSFFmpegArgsAt(cfg, probe, tmpDir, 0, 0)
}
-// EncoderProfile names the codec + preset + decoder hint combination the HLS
-// pipeline picks for the given hardware backend + transcode config. Exposed
-// so callers can log the chosen encoder before ffmpeg launches and so both
-// the demuxer-side `-hwaccel` flag and the encoder-side argv stay in sync
-// (otherwise the two switches in buildHLSFFmpegArgsAt could silently drift
-// when adding a new backend).
-type EncoderProfile struct {
- Codec string // ffmpeg encoder name (e.g. "h264_nvenc", "libx264")
- Preset string // preset string, or "" when the codec has no preset knob
- DecodeHwAccel string // ffmpeg `-hwaccel` value (e.g. "cuda", "qsv", "vaapi"), or ""
-}
-
-// ResolveEncoderProfile mirrors the codec + preset selection inside
-// buildHLSFFmpegArgsAt so callers (registry, log lines, diagnostic
-// endpoints) can know what ffmpeg will be told to do without parsing argv.
-//
-// The configured preset is libx264-specific by vocabulary (ultrafast…
-// veryslow). Passing it through to NVENC / QSV would have ffmpeg reject
-// the argv (NVENC uses p1-p7, QSV uses its own subset). So vendor encoders
-// always use their hardcoded vendor preset and ignore configuredPreset.
-// VideoToolbox has no preset knob at all.
-//
-// DecodeHwAccel mirrors the encoder family — `-hwaccel cuda` for NVENC,
-// `-hwaccel qsv` for QSV, `-hwaccel vaapi` for VAAPI. We intentionally
-// do NOT pass `-hwaccel_output_format vaapi`: that pins decoded frames
-// to GPU memory, but our filter chain (scale/format/setparams) runs on
-// CPU and can't consume VAAPI surfaces. Keeping output frames on CPU
-// makes the filter chain work and the VAAPI encoder still benefits from
-// HW-accelerated DECODE on the input side.
-func ResolveEncoderProfile(hw HWAccel, configuredPreset string) EncoderProfile {
- codec := hw.FFmpegVideoCodec("h264")
- switch codec {
- case "libx264":
- preset := configuredPreset
- if preset == "" {
- preset = "superfast"
- }
- return EncoderProfile{Codec: codec, Preset: preset, DecodeHwAccel: ""}
- case "h264_nvenc":
- return EncoderProfile{Codec: codec, Preset: "p3", DecodeHwAccel: "cuda"}
- case "h264_qsv":
- return EncoderProfile{Codec: codec, Preset: "veryfast", DecodeHwAccel: "qsv"}
- case "h264_vaapi":
- return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: "vaapi"}
- case "h264_videotoolbox":
- // No preset knob for VideoToolbox; the speed/quality dial is `-q:v`.
- // VideoToolbox uses per-encoder flags rather than a demuxer hint.
- return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: ""}
- }
- // Unknown / future codecs: software path.
- return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: ""}
-}
-
// buildHLSFFmpegArgsAt returns the argv for an HLS encode that starts at the
// given segment index (`-ss `) and writes segments numbered from
// startIdx so they slot into the existing manifest at the correct position.
// `-output_ts_offset` keeps the segment PTS aligned with manifest timeline.
func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string, startIdx int, startSec float64) []string {
- profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
+ hwHint := cfg.Transcode.HWAccel
args := []string{"-y", "-hide_banner", "-loglevel", "warning"}
- // Demuxer-side HW-decode hint. Sourced from the profile so a future
- // codec/hint mismatch is impossible — the encoder + decode hint are
- // computed once and stay coherent. Notably we do NOT add
- // `-hwaccel_output_format vaapi` on the VAAPI path: that pins decoded
- // frames to GPU memory but our CPU filter chain (scale, format,
- // setparams) can't consume VAAPI surfaces. Letting frames flow on CPU
- // keeps the filter chain working; the encoder still gets HW-accelerated
- // decode on the input side.
- if profile.DecodeHwAccel != "" {
- args = append(args, "-hwaccel", profile.DecodeHwAccel)
+ switch hwHint {
+ case HWAccelNVENC:
+ args = append(args, "-hwaccel", "cuda")
+ case HWAccelQSV:
+ args = append(args, "-hwaccel", "qsv")
+ case HWAccelVAAPI:
+ args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi")
+ case HWAccelNone, HWAccelVideoToolbox:
+ // No demuxer-side hint.
}
// Seek before -i for fast keyframe-aligned start. The new ffmpeg writes
@@ -1131,58 +1011,24 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
}
args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx))
- // Video encode. Codec + preset come from the EncoderProfile resolved at
- // the top of this function so the demuxer hint, the encoder, and the
- // per-session log line all stay consistent.
- //
- // Defaults are biased for FIRST-START LATENCY over quality — the player
- // blocks on seg-0 before the first frame paints, and a slow seg-0 is
- // what users notice ("preparando sesión" stuck). Users who want better
- // quality can override via `download.transcode.preset` in config.toml.
- codec := profile.Codec
+ // Video encode.
+ codec := hwHint.FFmpegVideoCodec("h264")
args = append(args, "-c:v", codec)
+ // Encoder-specific tuning. Each HW encoder takes a different "preset"
+ // vocabulary; libx264 uses ultrafast→placebo, NVENC uses p1→p7, QSV uses
+ // veryfast→veryslow, VAAPI/VideoToolbox don't expose presets.
switch codec {
case "libx264":
- // superfast = ~15-20% faster than veryfast at marginal quality loss
- // for the bitrates we target (5-25 Mbps). For 4K software encodes
- // this is the difference between ~3 s and ~2.5 s per segment on a
- // recent x86 CPU. `-threads 0` is libx264's default but explicit
- // helps when the user has set GOMAXPROCS.
- args = append(args, "-preset", profile.Preset, "-threads", "0")
+ preset := cfg.Transcode.Preset
+ if preset == "" {
+ preset = "veryfast"
+ }
+ args = append(args, "-preset", preset)
case "h264_nvenc":
- // p3 + vbr keeps NVENC fast (~1.5 s seg-0) without the segmentation
- // breakage `-tune ll` introduced in 0.9.9: with -tune=ll the NVENC
- // rate control emits long IDR-less GOPs that ignore -force_key_frames,
- // so ffmpeg's HLS muxer never closes seg-0 and the player stalls at
- // "preparando sesión" until the 60 s mark-ready timeout. Verified on
- // ffmpeg 6.1.1 + driver 580 / RTX-class GPUs: dropping -tune ll
- // restores per-segment cuts at 27x real-time vs 28x with -tune ll.
- args = append(args, "-preset", profile.Preset, "-rc", "vbr")
+ // p4 = balanced quality/speed; p1 fastest, p7 highest quality.
+ args = append(args, "-preset", "p4", "-rc", "vbr", "-tune", "hq")
case "h264_qsv":
- // veryfast is the fastest realistic QSV preset; medium was too
- // conservative for first-start. look_ahead=0 keeps the encoder
- // truly low-latency (no rate-control look-ahead window).
- args = append(args, "-preset", profile.Preset, "-look_ahead", "0")
- case "h264_videotoolbox":
- // VideoToolbox has no "preset" knob; `-realtime` flips into the
- // low-latency path used by FaceTime. We let the `-b:v / -maxrate
- // / -bufsize` block (added later in this function) drive rate
- // control — adding `-q:v` here would conflict because ffmpeg's
- // videotoolbox encoder treats `-b:v` as authoritative and
- // silently ignores `-q:v`, so the constant-quality knob never
- // took effect anyway.
- args = append(args, "-realtime", "1")
- case "h264_vaapi":
- // h264_vaapi has no preset knob. Bitrate args (set later) drive
- // rate control. Add `-vaapi_device /dev/dri/renderD128` so the
- // encoder doesn't fall back to a NULL device on multi-GPU hosts
- // where the default render node is a non-VAAPI GPU (an Nvidia
- // dGPU's render node, etc.). The filter chain below switches to
- // `format=nv12,hwupload` so frames land on the right VAAPI
- // surface before the encoder; we intentionally avoid scale_vaapi
- // because mesa 25 + Raphael iGPU emits "Cannot allocate memory"
- // per session start, polluting logs even though encode succeeds.
- args = append(args, "-vaapi_device", "/dev/dri/renderD128")
+ args = append(args, "-preset", "medium", "-look_ahead", "0")
}
// Derive H.264 level from the actual output height. A fixed "4.0" caps the
// encoder at 1080p — anything taller (1440p, 4K source on quality=original)
@@ -1233,32 +1079,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if maxH == 0 {
maxH = cfg.Transcode.MaxHeight
}
- // VAAPI needs frames as nv12 VAAPI surfaces before the encoder. We do
- // scale + format conversion on CPU then `hwupload` once at the end —
- // skips the mesa 25 + Raphael iGPU "Cannot allocate memory" log spam
- // that scale_vaapi triggers per-session-start while still delivering
- // the encoder a GPU surface. setparams is dropped because VAAPI
- // surfaces don't expose VUI fields the way libx264 does; the encoder
- // records its own color metadata via the source PTS chain.
- pixFormat := "yuv420p"
- hwUploadTail := ""
- colorTail := ",setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
- if codec == "h264_vaapi" {
- pixFormat = "nv12"
- hwUploadTail = ",hwupload"
- colorTail = ""
- }
var filterChain string
if maxH > 0 && probe.Height > maxH {
filterChain = fmt.Sprintf(
- "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
- maxH, pixFormat, colorTail, hwUploadTail,
+ "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
+ maxH,
)
} else {
- filterChain = fmt.Sprintf(
- "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
- pixFormat, colorTail, hwUploadTail,
- )
+ filterChain = "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
}
args = append(args, "-vf", filterChain)
@@ -1331,10 +1159,6 @@ func (s *HLSSession) extractSubtitles(ctx context.Context) {
// renderVideoPlaylist builds the VOD media playlist for the video stream.
// Segment count is derived from the source duration — the player learns the
// total timeline from the manifest before any segment is fetched.
-//
-// seg-0 is the short init segment (hlsInitSegmentDuration s); seg-1 onward
-// are hlsSegmentDuration s each. The last segment may be shorter than the
-// nominal duration when (duration - init) doesn't divide evenly.
func renderVideoPlaylist(durationSec float64, segCount int) string {
var b strings.Builder
b.WriteString("#EXTM3U\n")
@@ -1345,7 +1169,7 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
remaining := durationSec
for i := 0; i < segCount; i++ {
- segDur := float64(segmentDurationFor(i))
+ segDur := float64(hlsSegmentDuration)
if remaining < segDur {
segDur = remaining
}
diff --git a/internal/engine/hls_test.go b/internal/engine/hls_test.go
index 7d4cde3..7c7cfa4 100644
--- a/internal/engine/hls_test.go
+++ b/internal/engine/hls_test.go
@@ -115,11 +115,10 @@ func TestRenderVideoPlaylist(t *testing.T) {
}
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)
+ // 9.5s total, 4s segments → 3 segs of 4/4/1.5
+ out := renderVideoPlaylist(9.5, 3)
if !strings.Contains(out, "#EXTINF:1.500,") {
- t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
+ t.Errorf("expected final segment 1.5s in playlist, got:\n%s", out)
}
}
diff --git a/internal/engine/hwaccel.go b/internal/engine/hwaccel.go
index d7d1bd4..7108379 100644
--- a/internal/engine/hwaccel.go
+++ b/internal/engine/hwaccel.go
@@ -86,117 +86,6 @@ func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
return string(out)
}
-// HWAccelDiagnostic bundles what we know about the host's ffmpeg + HW encode
-// capabilities so the daemon can log a single coherent line at startup and the
-// web side can surface "this agent is software-only" without re-running probes.
-type HWAccelDiagnostic struct {
- Pick HWAccel // backend selected by DetectHWAccel
- FFmpegPath string // resolved ffmpeg binary
- FFmpegVersion string // first line of `ffmpeg -version` (e.g. "ffmpeg version 6.1.1")
- Encoders []string // HW + libsvtav1/libvpx9-class encoders found in -encoders output
- Devices []string // device files / drivers detected at probe time
-}
-
-// DetectHWAccelDiagnostic returns the full diagnostic picture for the host's
-// transcode pipeline. Unlike DetectHWAccel, this is NOT cached — callers pay
-// for an ffmpeg subprocess on each call (one `-encoders`, one `-version`).
-// Daemon startup is the natural caller; per-session lookups should keep using
-// DetectHWAccel (cached) and only re-probe diagnostics if the user runs an
-// explicit doctor command.
-func DetectHWAccelDiagnostic(ctx context.Context, ffmpegPath string) HWAccelDiagnostic {
- d := HWAccelDiagnostic{Pick: HWAccelNone, FFmpegPath: ffmpegPath}
- if ffmpegPath == "" {
- return d
- }
- d.FFmpegVersion = ffmpegVersionLine(ctx, ffmpegPath)
- encoders := listFFmpegEncoders(ctx, ffmpegPath)
- for _, name := range hwEncoderNames {
- if strings.Contains(encoders, name) {
- d.Encoders = append(d.Encoders, name)
- }
- }
- // Device-file checks mirror the picks below so the log line tells the
- // reader why a present encoder might still have been rejected (e.g. NVENC
- // compiled in but /dev/nvidia0 missing inside a container).
- if fileExists("/dev/nvidia0") {
- d.Devices = append(d.Devices, "/dev/nvidia0")
- }
- if fileExists("/dev/dri/renderD128") {
- d.Devices = append(d.Devices, "/dev/dri/renderD128")
- }
- if hasNvidiaDriver() {
- d.Devices = append(d.Devices, "nvidia-smi")
- }
- d.Pick = DetectHWAccel(ctx, ffmpegPath)
- return d
-}
-
-// LogLine returns a one-line human-readable summary of the diagnostic,
-// suitable for daemon startup output. Format:
-//
-// "[transcode] ffmpeg 6.1.1 at /usr/bin/ffmpeg, HW=nvenc (h264_nvenc), devices=/dev/nvidia0,nvidia-smi"
-// "[transcode] ffmpeg 6.1.1 at /home/linuxbrew/.../ffmpeg, HW=none (software libx264) — no HW encoders compiled in"
-func (d HWAccelDiagnostic) LogLine() string {
- var b strings.Builder
- b.WriteString("[transcode] ")
- if d.FFmpegVersion != "" {
- b.WriteString(d.FFmpegVersion)
- } else {
- b.WriteString("ffmpeg")
- }
- if d.FFmpegPath != "" {
- b.WriteString(" at ")
- b.WriteString(d.FFmpegPath)
- }
- b.WriteString(", HW=")
- b.WriteString(string(d.Pick))
- if d.Pick == HWAccelNone {
- if len(d.Encoders) == 0 {
- b.WriteString(" (software libx264) — no HW encoders compiled in")
- } else {
- b.WriteString(" (software libx264) — encoders found but no matching device: ")
- b.WriteString(strings.Join(d.Encoders, ","))
- }
- } else {
- b.WriteString(" (")
- b.WriteString(d.Pick.FFmpegVideoCodec("h264"))
- b.WriteString(")")
- if len(d.Devices) > 0 {
- b.WriteString(", devices=")
- b.WriteString(strings.Join(d.Devices, ","))
- }
- }
- return b.String()
-}
-
-// hwEncoderNames lists the HW-accelerated encoders we care about for the
-// startup log. Kept in lookup order so the output reads predictably across
-// hosts.
-var hwEncoderNames = []string{
- "h264_nvenc", "hevc_nvenc",
- "h264_qsv", "hevc_qsv",
- "h264_vaapi", "hevc_vaapi",
- "h264_videotoolbox", "hevc_videotoolbox",
-}
-
-// ffmpegVersionLine extracts the "ffmpeg version X.Y.Z" prefix from
-// `ffmpeg -version`. Bounded to avoid hanging the daemon on a misbehaving
-// binary.
-func ffmpegVersionLine(ctx context.Context, ffmpegPath string) string {
- cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-version")
- out, err := cmd.CombinedOutput()
- if err != nil || len(out) == 0 {
- return ""
- }
- line, _, _ := strings.Cut(string(out), "\n")
- // "ffmpeg version 6.1.1-some-build-suffix Copyright..." → keep up to first
- // space after "version 6.x" to avoid spamming build flags into the log.
- if idx := strings.Index(line, "Copyright"); idx > 0 {
- line = strings.TrimSpace(line[:idx])
- }
- return strings.TrimSpace(line)
-}
-
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
diff --git a/internal/engine/hwaccel_test.go b/internal/engine/hwaccel_test.go
index cf3bec2..f022d29 100644
--- a/internal/engine/hwaccel_test.go
+++ b/internal/engine/hwaccel_test.go
@@ -1,9 +1,6 @@
package engine
-import (
- "strings"
- "testing"
-)
+import "testing"
func TestHWAccelFFmpegVideoCodec(t *testing.T) {
cases := []struct {
@@ -35,122 +32,3 @@ func TestDetectHWAccelEmptyPathReturnsNone(t *testing.T) {
t.Errorf("got %s, want %s", got, HWAccelNone)
}
}
-
-func TestResolveEncoderProfileDefaults(t *testing.T) {
- cases := []struct {
- hw HWAccel
- configured string
- wantCodec string
- wantPreset string
- wantHint string
- }{
- // Empty configured preset → pick latency-biased default per backend.
- // DecodeHwAccel matches the encoder family for HW encoders; libx264 +
- // VideoToolbox have no demuxer hint.
- {HWAccelNone, "", "libx264", "superfast", ""},
- {HWAccelNVENC, "", "h264_nvenc", "p3", "cuda"},
- {HWAccelQSV, "", "h264_qsv", "veryfast", "qsv"},
- // VAAPI: decoder hint set, no preset, no `-hwaccel_output_format vaapi`
- // (so the CPU filter chain can consume the decoded frames).
- {HWAccelVAAPI, "", "h264_vaapi", "", "vaapi"},
- // VideoToolbox has no preset knob — Preset should be "" regardless of input.
- // VideoToolbox uses per-encoder flags, not a demuxer `-hwaccel` hint.
- {HWAccelVideoToolbox, "p4", "h264_videotoolbox", "", ""},
- {HWAccelVideoToolbox, "", "h264_videotoolbox", "", ""},
- }
- for _, tc := range cases {
- got := ResolveEncoderProfile(tc.hw, tc.configured)
- if got.Codec != tc.wantCodec || got.Preset != tc.wantPreset || got.DecodeHwAccel != tc.wantHint {
- t.Errorf("ResolveEncoderProfile(%s, %q) = {codec=%s preset=%s hint=%s}, want {codec=%s preset=%s hint=%s}",
- tc.hw, tc.configured,
- got.Codec, got.Preset, got.DecodeHwAccel,
- tc.wantCodec, tc.wantPreset, tc.wantHint)
- }
- }
-}
-
-func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) {
- // Only libx264 honours the configured preset — the libx264 vocabulary
- // (ultrafast…veryslow) doesn't apply to vendor encoders. NVENC has its
- // own p1-p7 scale; QSV uses a different subset; VideoToolbox has no
- // preset knob. Passing a libx264 preset to them would have ffmpeg reject
- // the argv, so ResolveEncoderProfile always falls back to the hardcoded
- // vendor preset for non-libx264 codecs.
- cases := []struct {
- hw HWAccel
- configured string
- wantPreset string
- }{
- {HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours
- {HWAccelNone, "medium", "medium"}, // libx264 honours
- {HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3
- {HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab
- {HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast
- {HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset
- }
- for _, tc := range cases {
- got := ResolveEncoderProfile(tc.hw, tc.configured)
- if got.Preset != tc.wantPreset {
- t.Errorf("ResolveEncoderProfile(%s, %q).Preset = %q, want %q",
- tc.hw, tc.configured, got.Preset, tc.wantPreset)
- }
- }
-}
-
-func TestHWAccelDiagnosticLogLineNone(t *testing.T) {
- d := HWAccelDiagnostic{
- Pick: HWAccelNone,
- FFmpegPath: "/usr/local/bin/ffmpeg",
- FFmpegVersion: "ffmpeg version 6.1.1",
- Encoders: nil,
- Devices: nil,
- }
- line := d.LogLine()
- wantSubstrings := []string{
- "ffmpeg version 6.1.1",
- "/usr/local/bin/ffmpeg",
- "HW=none",
- "software libx264",
- "no HW encoders compiled in",
- }
- for _, want := range wantSubstrings {
- if !strings.Contains(line, want) {
- t.Errorf("expected substring %q in log line; got %q", want, line)
- }
- }
-}
-
-func TestHWAccelDiagnosticLogLineNVENCWithDevices(t *testing.T) {
- d := HWAccelDiagnostic{
- Pick: HWAccelNVENC,
- FFmpegPath: "/usr/bin/ffmpeg",
- FFmpegVersion: "ffmpeg version 6.0",
- Encoders: []string{"h264_nvenc", "hevc_nvenc", "h264_qsv"},
- Devices: []string{"/dev/nvidia0", "nvidia-smi"},
- }
- line := d.LogLine()
- for _, want := range []string{"HW=nvenc", "h264_nvenc", "/dev/nvidia0", "nvidia-smi"} {
- if !strings.Contains(line, want) {
- t.Errorf("expected substring %q in log line; got %q", want, line)
- }
- }
-}
-
-func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) {
- // Edge case: ffmpeg compiled WITH nvenc but no /dev/nvidia0 (container w/o GPU).
- // LogLine should flag the encoders so the user knows where the gap is.
- d := HWAccelDiagnostic{
- Pick: HWAccelNone,
- FFmpegPath: "/usr/bin/ffmpeg",
- FFmpegVersion: "ffmpeg version 6.0",
- Encoders: []string{"h264_nvenc"},
- Devices: nil,
- }
- line := d.LogLine()
- for _, want := range []string{"HW=none", "encoders found but no matching device", "h264_nvenc"} {
- if !strings.Contains(line, want) {
- t.Errorf("expected substring %q in log line; got %q", want, line)
- }
- }
-}
-
diff --git a/internal/engine/probe.go b/internal/engine/probe.go
index c29c81a..930b669 100644
--- a/internal/engine/probe.go
+++ b/internal/engine/probe.go
@@ -88,15 +88,7 @@ const (
)
// ProbeFile runs ffprobe and returns a StreamProbe view of the file.
-//
-// Result is memoised by (path, mtime, size) for probeCacheTTL — repeat plays
-// of the same file at the same quality (the HLS cache HIT path) skip ffprobe
-// entirely. ffprobe on a 50 GB MKV can cost 1-3 s; first-segment latency
-// shrinks by the same amount on the second play.
func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe, error) {
- if cached, ok := lookupProbeCache(filePath); ok {
- return cached, nil
- }
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
if err != nil {
return nil, fmt.Errorf("probe: %w", err)
@@ -144,7 +136,6 @@ func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe,
})
}
}
- storeProbeCache(filePath, probe)
return probe, nil
}
diff --git a/internal/engine/probe_cache.go b/internal/engine/probe_cache.go
deleted file mode 100644
index fcc7dec..0000000
--- a/internal/engine/probe_cache.go
+++ /dev/null
@@ -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)
-}
diff --git a/internal/engine/probe_cache_test.go b/internal/engine/probe_cache_test.go
deleted file mode 100644
index 76c79da..0000000
--- a/internal/engine/probe_cache_test.go
+++ /dev/null
@@ -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())
- }
-}
diff --git a/internal/engine/progress.go b/internal/engine/progress.go
index e5eefe0..eba8814 100644
--- a/internal/engine/progress.go
+++ b/internal/engine/progress.go
@@ -45,19 +45,10 @@ type ProgressReporter struct {
lastCheckAt time.Time // last time we reported for control-signal polling
}
-// NewProgressReporter creates a reporter that flushes every interval. A nil
-// 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.
+// NewProgressReporter creates a reporter that flushes every interval.
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
- var rep StatusReporter
- if ac != nil {
- rep = ac
- }
return &ProgressReporter{
- reporter: rep,
+ reporter: ac,
interval: interval,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
@@ -117,9 +108,6 @@ func (r *ProgressReporter) Run(ctx context.Context) error {
}
func (r *ProgressReporter) flush(ctx context.Context) {
- if r.reporter == nil {
- return // local-only reporter (one-shot): nothing to send
- }
r.mu.Lock()
tasks := make([]*Task, 0, len(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.
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()
if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go
index efcddbe..f4b1b6d 100644
--- a/internal/engine/torrent.go
+++ b/internal/engine/torrent.go
@@ -61,12 +61,7 @@ var defaultTrackers = []string{
// TorrentConfig holds settings for the BitTorrent downloader.
type TorrentConfig struct {
- 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
+ DataDir string
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)
MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
@@ -118,23 +113,7 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// Storage: mmap instead of default file backend.
// The library author notes file storage has "very high system overhead".
// mmap improves I/O throughput and piece verification speed significantly.
- //
- // 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)
- }
+ tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
// Fixed port for incoming peer connections (enables UPnP port mapping).
// With ListenPort=0, only ~30% of peers can connect to us.
@@ -373,13 +352,6 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
result.Method = MethodTorrent
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).
// The manager handles seeding lifecycle.
if !d.cfg.SeedEnabled {
@@ -487,41 +459,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.
func (d *TorrentDownloader) Pause(taskID string) error {
d.activeMu.Lock()
diff --git a/internal/engine/vaapi_args_test.go b/internal/engine/vaapi_args_test.go
deleted file mode 100644
index 33d0786..0000000
--- a/internal/engine/vaapi_args_test.go
+++ /dev/null
@@ -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, " "))
-}
diff --git a/internal/engine/validate.go b/internal/engine/validate.go
index 0efd4de..dd07516 100644
--- a/internal/engine/validate.go
+++ b/internal/engine/validate.go
@@ -21,27 +21,12 @@ var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
// 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 (