diff --git a/.github/workflows/ci.yml b/.forgejo/workflows/ci.yml
similarity index 61%
rename from .github/workflows/ci.yml
rename to .forgejo/workflows/ci.yml
index 7dabcc4..82ee799 100644
--- a/.github/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -12,35 +12,26 @@ permissions:
jobs:
test:
name: Test
- runs-on: ubuntu-latest
- strategy:
- matrix:
- go-version: ["1.25"]
+ runs-on: docker
+ container:
+ image: docker.io/library/golang:1.25
steps:
- - uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: ${{ matrix.go-version }}
+ - uses: actions/checkout@v4
- name: Run tests
run: go test -v -race -count=1 ./...
build:
name: Build
- runs-on: ubuntu-latest
+ runs-on: docker
+ container:
+ image: docker.io/library/golang:1.25
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- - uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: "1.25"
+ - uses: actions/checkout@v4
- name: Build
env:
@@ -50,30 +41,30 @@ jobs:
lint:
name: Lint
- runs-on: ubuntu-latest
+ runs-on: docker
+ container:
+ image: docker.io/library/golang:1.25
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: "1.25"
+ - name: Install golangci-lint
+ run: |
+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \
+ | sh -s -- -b /usr/local/bin v2.11.4
- name: Run golangci-lint
- uses: golangci/golangci-lint-action@v9
- with:
- version: v2.11.4
+ run: golangci-lint run ./...
coverage:
name: Coverage
- runs-on: ubuntu-latest
+ runs-on: docker
+ container:
+ image: docker.io/library/golang:1.25
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v4
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: "1.25"
+ - name: Install python3
+ run: apt-get update && apt-get install -y --no-install-recommends python3
- name: Run tests with coverage (all packages)
run: |
@@ -102,24 +93,13 @@ jobs:
print('OK: Coverage meets minimum threshold')
"
- - name: Upload coverage to Codecov
- uses: codecov/codecov-action@v6
- with:
- files: ./coverage.out
- fail_ci_if_error: false
- env:
- CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
-
vet:
name: Vet
- runs-on: ubuntu-latest
+ runs-on: docker
+ container:
+ image: docker.io/library/golang:1.25
steps:
- - uses: actions/checkout@v6
-
- - name: Set up Go
- uses: actions/setup-go@v6
- with:
- go-version: "1.25"
+ - uses: actions/checkout@v4
- name: Run go vet
run: go vet ./...
diff --git a/.forgejo/workflows/docker-rebuild.yml b/.forgejo/workflows/docker-rebuild.yml
new file mode 100644
index 0000000..34cc3d6
--- /dev/null
+++ b/.forgejo/workflows/docker-rebuild.yml
@@ -0,0 +1,61 @@
+# Rebuilds and re-pushes the `latest` image without a version bump so newly
+# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
+# tags are immutable and never touched here. Runs weekly and on demand.
+name: Docker rebuild
+
+on:
+ schedule:
+ # Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
+ - cron: "17 4 * * 1"
+ workflow_dispatch:
+
+jobs:
+ rebuild:
+ runs-on: docker
+ container:
+ image: docker.io/library/docker:27-cli
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install build deps
+ run: apk add --no-cache curl git bash
+
+ - name: Install buildx
+ run: |
+ mkdir -p ~/.docker/cli-plugins
+ curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
+ -o ~/.docker/cli-plugins/docker-buildx
+ chmod +x ~/.docker/cli-plugins/docker-buildx
+
+ - name: Set up qemu
+ run: docker run --rm --privileged tonistiigi/binfmt --install all
+
+ # Stamp the binary with the most recent release tag (not "dev").
+ - name: Resolve version
+ id: ver
+ run: |
+ v=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)
+ echo "version=$v" >> "$GITHUB_OUTPUT"
+
+ - name: Login to Docker Hub
+ env:
+ DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
+ DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
+
+ - name: Build + push (refresh latest)
+ env:
+ VERSION: ${{ steps.ver.outputs.version }}
+ run: |
+ docker buildx create --name builder --use --driver docker-container
+ # Refresh the floating tag only — never overwrite a versioned release.
+ # Force a fresh base pull so apk upgrade picks up new patches.
+ docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ --build-arg "VERSION=$VERSION" \
+ --tag "torrentclaw/unarr:latest" \
+ --no-cache \
+ --push \
+ .
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
new file mode 100644
index 0000000..d757612
--- /dev/null
+++ b/.forgejo/workflows/release.yml
@@ -0,0 +1,118 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: docker
+ container:
+ image: docker.io/library/golang:1.25
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Install build deps (bash, curl, jq, ffmpeg fetch deps)
+ run: |
+ apt-get update
+ apt-get install -y --no-install-recommends bash curl ca-certificates jq xz-utils unzip
+
+ - name: Install goreleaser
+ run: |
+ curl -sSfL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz \
+ | tar -xz -C /usr/local/bin goreleaser
+
+ - name: Run goreleaser
+ env:
+ # Forgejo runner auto-injects GITHUB_TOKEN (a per-job, instance-scoped
+ # token usable against the Forgejo REST API). goreleaser only accepts
+ # one token; with both GITHUB_TOKEN + GITEA_TOKEN set it errors out
+ # ("multiple tokens"). Unset GITHUB_TOKEN before invoking goreleaser so
+ # it picks the Gitea code path + the gitea_urls block in .goreleaser.yml.
+ GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
+ # Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
+ # accepts it and the resulting binary disables signature checks
+ # (back-compat: pre-signing releases continue to update). Set
+ # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
+ # to turn verification on.
+ RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
+ run: |
+ unset GITHUB_TOKEN
+ goreleaser release --clean
+
+ - name: Sign checksums.txt with ed25519
+ if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
+ env:
+ RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # Tailscale IP — domain-agnostic; the runner shares the dokploy-network with
+ # forgejo (hostname `forgejo`), so the in-cluster hostname is fastest, but the
+ # Tailscale IP is the documented fallback.
+ FORGEJO_API: http://forgejo:3000/api/v1
+ REPO: torrentclaw/unarr
+ run: |
+ set -euo pipefail
+ go run ./scripts/sign-checksums \
+ -key "$RELEASE_SIGNING_KEY" \
+ -in dist/checksums.txt \
+ -out dist/checksums.txt.sig
+
+ # Find the release ID for this tag, then upload the sig as an asset.
+ rel_id=$(curl -sSf "$FORGEJO_API/repos/$REPO/releases/tags/$RELEASE_TAG" \
+ -H "Authorization: token $FORGEJO_TOKEN" | jq -r '.id')
+ curl -sSf -X POST \
+ "$FORGEJO_API/repos/$REPO/releases/$rel_id/assets?name=checksums.txt.sig" \
+ -H "Authorization: token $FORGEJO_TOKEN" \
+ -F "attachment=@dist/checksums.txt.sig"
+
+ docker:
+ needs: release
+ runs-on: docker
+ container:
+ # Docker-in-Docker capable image — buildx + qemu pre-installed.
+ image: docker.io/library/docker:27-cli
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Install buildx
+ run: |
+ apk add --no-cache curl
+ mkdir -p ~/.docker/cli-plugins
+ curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
+ -o ~/.docker/cli-plugins/docker-buildx
+ chmod +x ~/.docker/cli-plugins/docker-buildx
+
+ - name: Login to Docker Hub
+ env:
+ DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
+ DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
+
+ - name: Set up qemu
+ run: docker run --rm --privileged tonistiigi/binfmt --install all
+
+ - name: Build + push multi-arch image
+ env:
+ VERSION: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+ VERSION_SEMVER="${VERSION#v}"
+ MAJOR_MINOR="${VERSION_SEMVER%.*}"
+ docker buildx create --name builder --use --driver docker-container
+ docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ --build-arg "VERSION=$VERSION" \
+ --tag "torrentclaw/unarr:$VERSION_SEMVER" \
+ --tag "torrentclaw/unarr:$MAJOR_MINOR" \
+ --tag "torrentclaw/unarr:latest" \
+ --push \
+ .
diff --git a/.github/workflows/docker-rebuild.yml b/.github/workflows/docker-rebuild.yml
deleted file mode 100644
index c1634f1..0000000
--- a/.github/workflows/docker-rebuild.yml
+++ /dev/null
@@ -1,52 +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: 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
deleted file mode 100644
index d0c683d..0000000
--- a/.github/workflows/pages.yml
+++ /dev/null
@@ -1,52 +0,0 @@
-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
deleted file mode 100644
index dcb49ce..0000000
--- a/.github/workflows/release.yml
+++ /dev/null
@@ -1,210 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - "v*"
-
-permissions:
- contents: write
-
-jobs:
- release:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
-
- - uses: actions/setup-go@v6
- with:
- go-version-file: go.mod
-
- - uses: goreleaser/goreleaser-action@v6
- with:
- version: "~> v2"
- args: release --clean
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
- # 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 81f1284..8015bab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,4 +41,7 @@ dist-ffbinaries/
# Docker
tmp/
config/
-dist-ffbinaries/
\ No newline at end of file
+dist-ffbinaries/
+
+# Claude Code: keep entirely local, do not track
+.claude/
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 26ce802..6bc4a51 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -59,6 +59,22 @@ changelog:
- "^test:"
- "^chore:"
+# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN +
+# these URLs and publishes the release there instead of GitHub. Reachable via
+# `forgejo` hostname inside the dokploy-network (the runner shares it); for
+# local goreleaser runs outside the network, override via env GITEA_API_URL.
+#
+# In goreleaser v2 `gitea_urls` is a top-level key (was nested under `release`
+# in v1).
+gitea_urls:
+ api: http://forgejo:3000/api/v1
+ download: https://git.torrentclaw.com
+ skip_tls_verify: false
+
+release:
+ draft: false
+ prerelease: auto
+
# Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
# brews:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 534bd99..de1dd6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,63 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [0.9.15] - 2026-05-27
+
+
+### Added
+
+- **sentry**: enhance error handling by skipping user input errors in CaptureError
+
+### Changed
+
+- **ci**: point Forgejo URLs at torrentclaw org (post-transfer)
+- **sentry**: decouple agent import via string-match, rename predicate
+
+### Documentation
+
+- **positioning**: reframe unarr around download/stream/transcode, drop misleading search-first wording
+
+### Fixed
+
+- **ci**: unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
+- **sentry**: skip "daemon not running" stop/reload errors
+
+### Other
+
+- **scripts**: harden release.sh against double-release and inline version bumps
+- untrack .claude/ (private local config)
+## [0.9.14] - 2026-05-27
+
+
+### Added
+
+- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
+
+### CI/CD
+
+- port workflows from .github/ to .forgejo/ (Forgejo Actions)
+
+### Fixed
+
+- **daemon**: defensive IsClosed check in watchSessionReady poll loop
+- **daemon**: use parent ctx for MarkSessionReady so cancel propagates
+- **release**: move gitea_urls to top-level (goreleaser v2 schema)
+## [0.9.13] - 2026-05-27
+
+
+### Added
+
+- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13)
+- **agent**: send full transcoder diagnostic in register payload (0.9.12)
+
+### Fixed
+
+- **daemon**: defer probeCancel so a panic mid-diagnostic still releases ctx
+
+### Other
+
+- **release**: add ship.sh end-to-end pipeline as GH Actions backup
+- **skills**: add /publish slash command + allow .claude/ in git
## [0.9.11] - 2026-05-27
@@ -22,6 +79,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **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
@@ -484,6 +545,9 @@ 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.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
diff --git a/DOCKERHUB.md b/DOCKERHUB.md
index 7a9bc0e..3df5b70 100644
--- a/DOCKERHUB.md
+++ b/DOCKERHUB.md
@@ -1,8 +1,9 @@
# unarr
-**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.
+**The single binary that replaces your whole *arr stack.** Built-in torrent,
+debrid, and usenet engines. Stream, transcode, and organize your library from
+one terminal — or run it as a headless daemon with a web dashboard, WireGuard
+split-tunnel, and Cloudflare Funnel remote access.
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
diff --git a/Makefile b/Makefile
index 08462b6..b3325bc 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
+.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push
BINARY = unarr
SENTRY_DSN ?=
@@ -71,6 +71,19 @@ release-dry:
@test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; }
@./scripts/release.sh --dry-run $(V)
+## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions.
+## Reads version from internal/cmd/version.go unless V= is provided.
+ship:
+ @./scripts/ship.sh $(V)
+
+## Ship + git push tag to GH afterwards
+ship-push:
+ @./scripts/ship.sh --push $(V)
+
+## Preview ship steps without executing
+ship-dry:
+ @./scripts/ship.sh --dry-run $(V)
+
## Remove generated files
clean:
rm -f $(BINARY) coverage.out coverage.html
diff --git a/README.md b/README.md
index 8a5d26d..75c9c62 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,9 @@
[](LICENSE)
[](go.mod)
-Powerful terminal tool for torrent search and management. **Free and open source.**
+The single-binary terminal client for torrent, debrid, and usenet downloads. **Free and open source.**
-Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
+Built-in torrent engine, debrid (Real-Debrid / AllDebrid), and NZB support. Stream to mpv/vlc, transcode on the fly with hardware acceleration, and manage your library — one binary or a headless daemon with WireGuard split-tunnel and Cloudflare Funnel remote access.
diff --git a/internal/agent/client.go b/internal/agent/client.go
index e60b0a4..e7f2c37 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -109,6 +109,27 @@ 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 68a187f..f7994fb 100644
--- a/internal/agent/daemon.go
+++ b/internal/agent/daemon.go
@@ -28,7 +28,15 @@ type DaemonConfig struct {
ScanPaths []string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
- AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
+ // Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon
+ // start. Surfaced in the web "Diagnose transcoder" modal — lets a user
+ // see which encoders the ffmpeg binary supports and which devices the
+ // host exposes without running `unarr probe-hwaccel`.
+ FFmpegVersion string // first line of `ffmpeg -version`
+ FFmpegPath string // resolved binary path
+ HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders`
+ HWDevices []string // device files + driver bins detected at probe time
+ AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
}
// Daemon manages agent registration and the sync loop.
@@ -122,6 +130,10 @@ 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 1f00033..cc08ae5 100644
--- a/internal/agent/state.go
+++ b/internal/agent/state.go
@@ -2,6 +2,8 @@ package agent
import (
"encoding/json"
+ "errors"
+ "fmt"
"os"
"path/filepath"
"time"
@@ -9,6 +11,13 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
+// ErrDaemonNotRunning is returned when no daemon state file exists on disk.
+// Callers may wrap it with %w; downstream code uses errors.Is to detect it.
+// NOTE: the message text is matched by the sentry package (string-match, to
+// avoid an import cycle). Keep the prefix "daemon does not appear to be
+// running" stable, or update sentry.daemonNotRunningMarker accordingly.
+var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
+
// DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct {
AgentID string `json:"agentId"`
@@ -69,17 +78,31 @@ func WriteState(state *DaemonState) {
os.Rename(tmp, path)
}
-// ReadState reads the daemon state from disk. Returns nil if not found.
+// ReadState reads the daemon state from disk. Returns nil if not found or
+// unreadable. Use LoadState when callers need to distinguish "not running"
+// from "state file corrupted".
func ReadState() *DaemonState {
+ state, _ := LoadState()
+ return state
+}
+
+// LoadState reads the daemon state and returns explicit errors:
+// - ErrDaemonNotRunning when the state file does not exist
+// - a wrapped json error when the file exists but cannot be decoded
+// (a real bug worth reporting to Sentry)
+func LoadState() (*DaemonState, error) {
data, err := os.ReadFile(StateFilePath())
if err != nil {
- return nil
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, ErrDaemonNotRunning
+ }
+ return nil, err
}
var state DaemonState
- if json.Unmarshal(data, &state) != nil {
- return nil
+ if err := json.Unmarshal(data, &state); err != nil {
+ return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
}
- return &state
+ return &state, nil
}
// RemoveState deletes the state file (called on clean shutdown).
diff --git a/internal/agent/state_test.go b/internal/agent/state_test.go
index 6c9abdd..7e275be 100644
--- a/internal/agent/state_test.go
+++ b/internal/agent/state_test.go
@@ -1,6 +1,7 @@
package agent
import (
+ "errors"
"os"
"path/filepath"
"testing"
@@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
}
}
+
+func TestLoadStateNotFound(t *testing.T) {
+ tmpDir := t.TempDir()
+ origFn := stateFilePathFn
+ stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") }
+ defer func() { stateFilePathFn = origFn }()
+
+ state, err := LoadState()
+ if state != nil {
+ t.Errorf("LoadState() state = %+v, want nil", state)
+ }
+ if !errors.Is(err, ErrDaemonNotRunning) {
+ t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err)
+ }
+}
+
+func TestLoadStateCorruptedJSON(t *testing.T) {
+ tmpDir := t.TempDir()
+ origFn := stateFilePathFn
+ path := filepath.Join(tmpDir, "daemon.state.json")
+ stateFilePathFn = func() string { return path }
+ defer func() { stateFilePathFn = origFn }()
+
+ os.WriteFile(path, []byte("not valid json{{{"), 0o644)
+
+ state, err := LoadState()
+ if state != nil {
+ t.Errorf("LoadState() state = %+v, want nil", state)
+ }
+ if err == nil {
+ t.Fatal("LoadState() err = nil, want decode error")
+ }
+ if errors.Is(err, ErrDaemonNotRunning) {
+ t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry")
+ }
+}
diff --git a/internal/agent/types.go b/internal/agent/types.go
index 00802bc..ae87bb6 100644
--- a/internal/agent/types.go
+++ b/internal/agent/types.go
@@ -26,6 +26,15 @@ 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 b0cca22..2e0c074 100644
--- a/internal/cmd/daemon.go
+++ b/internal/cmd/daemon.go
@@ -143,7 +143,19 @@ func runDaemonStart() error {
// is what the web side uses to decide whether the user should pre-empt
// transcoding by downloading a smaller version (4K source on a software
// libx264-only host is the canonical case where pre-download wins).
- hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath)
+ //
+ // Use the full diagnostic (encoders + devices + ffmpeg version) instead
+ // of just the picked backend — the extra fields ride along in the
+ // register payload so the web "Diagnose transcoder" modal can show *why*
+ // libx264 was selected on a host with a GPU (e.g. brew's ffmpeg without
+ // --enable-nvenc). 10 s ceiling so a hung ffmpeg binary can't stall
+ // startup forever.
+ ffmpegResolved, _ := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
+ probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer probeCancel() // guard against a panic inside DetectHWAccelDiagnostic
+ hwDiag := engine.DetectHWAccelDiagnostic(probeCtx, ffmpegResolved)
+ log.Println(hwDiag.LogLine())
+ hwAccelPick := hwDiag.Pick
maxTranscodeHeight := 1080
if hwAccelPick != engine.HWAccelNone {
maxTranscodeHeight = 2160
@@ -162,6 +174,10 @@ 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(),
}
@@ -596,6 +612,11 @@ func runDaemonStart() error {
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)
}()
}
@@ -924,3 +945,48 @@ func mirrorCORSOrigins(parent context.Context, cfg config.Config, userAgent stri
}
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 558fb26..4ac4d10 100644
--- a/internal/cmd/daemon_control.go
+++ b/internal/cmd/daemon_control.go
@@ -1,6 +1,7 @@
package cmd
import (
+ "errors"
"fmt"
"os"
"os/exec"
@@ -262,9 +263,12 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
- state := agent.ReadState()
- if state == nil {
- return fmt.Errorf("daemon does not appear to be running (state file not found)")
+ state, err := agent.LoadState()
+ if err != nil {
+ if errors.Is(err, agent.ErrDaemonNotRunning) {
+ return err
+ }
+ return fmt.Errorf("read daemon state: %w", err)
}
return killPID(state.PID)
}
diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go
index 056112f..34d8e4d 100644
--- a/internal/cmd/reload_unix.go
+++ b/internal/cmd/reload_unix.go
@@ -3,6 +3,7 @@
package cmd
import (
+ "errors"
"fmt"
"log"
"os"
@@ -43,9 +44,12 @@ func startReloadWatcher(rc *ReloadableConfig) {
// sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error {
- state := agent.ReadState()
- if state == nil {
- return fmt.Errorf("daemon does not appear to be running (state file not found)")
+ state, err := agent.LoadState()
+ if err != nil {
+ if errors.Is(err, agent.ErrDaemonNotRunning) {
+ return err
+ }
+ return fmt.Errorf("read daemon state: %w", err)
}
p, err := os.FindProcess(state.PID)
if err != nil {
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index b28ec92..375d8e9 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -25,16 +25,20 @@ var (
func init() {
rootCmd = &cobra.Command{
- Use: "unarr",
- Short: "unarr — torrent search and management",
- Long: `unarr is a powerful terminal tool for torrent search and management.
-
-Search 30+ torrent sources, inspect torrent quality, discover popular content,
-find streaming providers, and manage your media collection — all from your terminal.
+ Use: "unarr",
+ Version: Version,
+ Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
+ Long: `unarr is a terminal-native client that downloads torrents, debrid links,
+and usenet (NZB) — all from the same binary. It streams content straight
+to mpv/vlc with sequential piece prioritization, transcodes on the fly via
+ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
+organizes your library into Movies/TV folders. Run it one-shot or as a
+long-running daemon with a built-in WireGuard split-tunnel and remote
+playback over Cloudflare Funnel.
Get started:
unarr init First-time configuration wizard
- unarr search "breaking bad" Search for content
+ unarr download Grab a torrent one-shot
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
@@ -55,7 +59,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
- &cobra.Group{ID: "search", Title: "Search & Discovery:"},
+ &cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
diff --git a/internal/cmd/version.go b/internal/cmd/version.go
index 7ed3030..194e3c0 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.11"
+var Version = "0.9.15"
diff --git a/internal/engine/hls.go b/internal/engine/hls.go
index 634f193..86219d5 100644
--- a/internal/engine/hls.go
+++ b/internal/engine/hls.go
@@ -519,6 +519,28 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
}
+// ReadyCount returns how many segments are currently fully on disk.
+// Caller can `>= 1` it to check whether seg-0 has landed (and so the
+// player can be told to attach). For cache-HIT sessions this is always
+// `segmentCount` from the moment StartHLSSession returns.
+func (s *HLSSession) ReadyCount() int {
+ s.readyMu.Lock()
+ defer s.readyMu.Unlock()
+ return s.readyMax
+}
+
+// FromCache reports whether this session was served from the HLS cache
+// (no ffmpeg subprocess spawned). Used by ready-watcher logic to short-
+// circuit polling — a cache HIT is ready the moment we return.
+func (s *HLSSession) FromCache() bool { return s.fromCache }
+
+// IsClosed reports whether Close() has been invoked. Exposed (vs the
+// internal isClosed) so external watchers — the ready-webhook
+// goroutine in cmd/daemon.go — can short-circuit polling on a session
+// that was torn down through a different code path (registry replace,
+// idle sweep) without racing on the unexported helper.
+func (s *HLSSession) IsClosed() bool { return s.isClosed() }
+
// MasterPlaylist returns the rendered master.m3u8 contents.
func (s *HLSSession) MasterPlaylist() string { return s.manifestRoot }
@@ -1146,6 +1168,17 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
// silently ignores `-q:v`, so the constant-quality knob never
// took effect anyway.
args = append(args, "-realtime", "1")
+ case "h264_vaapi":
+ // h264_vaapi has no preset knob. Bitrate args (set later) drive
+ // rate control. Add `-vaapi_device /dev/dri/renderD128` so the
+ // encoder doesn't fall back to a NULL device on multi-GPU hosts
+ // where the default render node is a non-VAAPI GPU (an Nvidia
+ // dGPU's render node, etc.). The filter chain below switches to
+ // `format=nv12,hwupload` so frames land on the right VAAPI
+ // surface before the encoder; we intentionally avoid scale_vaapi
+ // because mesa 25 + Raphael iGPU emits "Cannot allocate memory"
+ // per session start, polluting logs even though encode succeeds.
+ args = append(args, "-vaapi_device", "/dev/dri/renderD128")
}
// Derive H.264 level from the actual output height. A fixed "4.0" caps the
// encoder at 1080p — anything taller (1440p, 4K source on quality=original)
@@ -1196,14 +1229,32 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if maxH == 0 {
maxH = cfg.Transcode.MaxHeight
}
+ // VAAPI needs frames as nv12 VAAPI surfaces before the encoder. We do
+ // scale + format conversion on CPU then `hwupload` once at the end —
+ // skips the mesa 25 + Raphael iGPU "Cannot allocate memory" log spam
+ // that scale_vaapi triggers per-session-start while still delivering
+ // the encoder a GPU surface. setparams is dropped because VAAPI
+ // surfaces don't expose VUI fields the way libx264 does; the encoder
+ // records its own color metadata via the source PTS chain.
+ pixFormat := "yuv420p"
+ hwUploadTail := ""
+ colorTail := ",setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
+ if codec == "h264_vaapi" {
+ pixFormat = "nv12"
+ hwUploadTail = ",hwupload"
+ colorTail = ""
+ }
var filterChain string
if maxH > 0 && probe.Height > maxH {
filterChain = fmt.Sprintf(
- "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
- maxH,
+ "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
+ maxH, pixFormat, colorTail, hwUploadTail,
)
} else {
- filterChain = "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
+ filterChain = fmt.Sprintf(
+ "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
+ pixFormat, colorTail, hwUploadTail,
+ )
}
args = append(args, "-vf", filterChain)
diff --git a/internal/engine/vaapi_args_test.go b/internal/engine/vaapi_args_test.go
new file mode 100644
index 0000000..33d0786
--- /dev/null
+++ b/internal/engine/vaapi_args_test.go
@@ -0,0 +1,97 @@
+package engine
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestBuildHLSFFmpegArgsVAAPI(t *testing.T) {
+ cfg := HLSSessionConfig{
+ SessionID: "test",
+ SourcePath: "/tmp/test.mkv",
+ Quality: "720p",
+ AudioIndex: 0,
+ Transcode: TranscodeRuntime{
+ FFmpegPath: "/usr/bin/ffmpeg",
+ FFprobePath: "/usr/bin/ffprobe",
+ HWAccel: HWAccelVAAPI,
+ },
+ }
+ probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
+ args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0)
+ got := strings.Join(args, " ")
+
+ wants := []string{
+ "-hwaccel vaapi",
+ "-vaapi_device /dev/dri/renderD128",
+ "-c:v h264_vaapi",
+ "format=nv12",
+ "hwupload",
+ }
+ for _, want := range wants {
+ if !strings.Contains(got, want) {
+ t.Errorf("argv missing %q\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "scale_vaapi") {
+ t.Errorf("argv unexpectedly contains scale_vaapi (mesa bug): %s", got)
+ }
+ if strings.Contains(got, "format=yuv420p") {
+ t.Errorf("argv contains format=yuv420p (libx264 path) for VAAPI codec: %s", got)
+ }
+}
+
+func TestBuildHLSFFmpegArgsLibx264NoRegression(t *testing.T) {
+ cfg := HLSSessionConfig{
+ SessionID: "test",
+ SourcePath: "/tmp/test.mkv",
+ Quality: "720p",
+ AudioIndex: 0,
+ Transcode: TranscodeRuntime{
+ FFmpegPath: "/usr/bin/ffmpeg",
+ FFprobePath: "/usr/bin/ffprobe",
+ HWAccel: HWAccelNone,
+ },
+ }
+ probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
+ args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0)
+ got := strings.Join(args, " ")
+ for _, want := range []string{"-c:v libx264", "format=yuv420p", "setparams=colorspace=bt709"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("libx264 argv missing %q: %s", want, got)
+ }
+ }
+ for _, bad := range []string{"-vaapi_device", "format=nv12", "hwupload"} {
+ if strings.Contains(got, bad) {
+ t.Errorf("libx264 argv unexpectedly contains %q: %s", bad, got)
+ }
+ }
+}
+
+// TestBuildHLSFFmpegArgsVAAPIDump prints the full argv buildHLSFFmpegArgsAt
+// emits for a typical VAAPI session. Mimics the daemon spawn step so the
+// operator can verify the ffmpeg command-line shape without booting the
+// stack — equivalent to `journalctl --user -u unarr-dev | grep ffmpeg`
+// but without waiting for a real player session.
+func TestBuildHLSFFmpegArgsVAAPIDump(t *testing.T) {
+ cfg := HLSSessionConfig{
+ SessionID: "vaapi-smoke",
+ SourcePath: "/mnt/nas/peliculas/sample.mkv",
+ Quality: "720p",
+ AudioIndex: -1,
+ Transcode: TranscodeRuntime{
+ FFmpegPath: "/usr/bin/ffmpeg",
+ FFprobePath: "/usr/bin/ffprobe",
+ HWAccel: HWAccelVAAPI,
+ },
+ }
+ probe := &StreamProbe{
+ VideoCodec: "hevc",
+ Width: 3840,
+ Height: 2160,
+ DurationSec: 5400,
+ AudioTracks: []ProbeAudioTrack{{Index: 0, Lang: "en", Codec: "ac3"}},
+ }
+ args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/smoke-tmpdir", 0, 0)
+ t.Logf("ffmpeg %s", strings.Join(args, " "))
+}
diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go
index 633fc0d..3f16c08 100644
--- a/internal/sentry/sentry.go
+++ b/internal/sentry/sentry.go
@@ -1,12 +1,14 @@
package sentry
import (
+ "errors"
"os"
"runtime"
"strings"
"time"
gosentry "github.com/getsentry/sentry-go"
+ "github.com/spf13/pflag"
)
// dsn is injected at build time via ldflags. If empty, Sentry is disabled.
@@ -44,9 +46,16 @@ func Close() {
gosentry.Flush(flushTimeout)
}
+// daemonNotRunningMarker matches the message of agent.ErrDaemonNotRunning
+// without importing the agent package — avoids a sentry → agent dependency
+// that would risk a cycle if agent ever needed to report errors itself.
+const daemonNotRunningMarker = "daemon does not appear to be running"
+
// CaptureError sends a non-fatal error to Sentry with optional command context.
+// Expected non-bug errors (bad CLI input, daemon not running) are skipped to
+// keep the issue feed signal-heavy.
func CaptureError(err error, command string) {
- if err == nil {
+ if err == nil || shouldSkipSentry(err) {
return
}
@@ -58,6 +67,21 @@ func CaptureError(err error, command string) {
})
}
+func shouldSkipSentry(err error) bool {
+ var notExist *pflag.NotExistError
+ var valueReq *pflag.ValueRequiredError
+ var invalidVal *pflag.InvalidValueError
+ var invalidSyn *pflag.InvalidSyntaxError
+ if errors.As(err, ¬Exist) || errors.As(err, &valueReq) ||
+ errors.As(err, &invalidVal) || errors.As(err, &invalidSyn) {
+ return true
+ }
+ msg := err.Error()
+ return strings.HasPrefix(msg, "unknown command ") ||
+ strings.HasPrefix(msg, "required flag(s)") ||
+ strings.Contains(msg, daemonNotRunningMarker)
+}
+
// RecoverPanic captures a panic and re-panics after reporting.
// Usage: defer sentry.RecoverPanic()
func RecoverPanic() {
diff --git a/internal/sentry/sentry_test.go b/internal/sentry/sentry_test.go
index 671e641..4005d14 100644
--- a/internal/sentry/sentry_test.go
+++ b/internal/sentry/sentry_test.go
@@ -1,6 +1,10 @@
package sentry
-import "testing"
+import (
+ "errors"
+ "fmt"
+ "testing"
+)
func TestEnvironment(t *testing.T) {
tests := []struct {
@@ -45,3 +49,16 @@ func TestSetUser(t *testing.T) {
// Should not panic without initialization
SetUser("agent-123")
}
+
+func TestShouldSkipSentryDaemonNotRunning(t *testing.T) {
+ // String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel
+ // is reworded, this test fails loudly so the marker can be updated.
+ err := errors.New("daemon does not appear to be running (state file not found)")
+ if !shouldSkipSentry(err) {
+ t.Error("ErrDaemonNotRunning message should be skipped")
+ }
+ wrapped := fmt.Errorf("read daemon state: %w", err)
+ if !shouldSkipSentry(wrapped) {
+ t.Error("wrapped ErrDaemonNotRunning message should be skipped")
+ }
+}
diff --git a/scripts/release.sh b/scripts/release.sh
index da9b911..46862be 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -55,6 +55,17 @@ fi
CURRENT_BRANCH=$(git branch --show-current)
[ "$CURRENT_BRANCH" = "main" ] || warn "Not on main branch (current: $CURRENT_BRANCH)"
+HEAD_SUBJECT=$(git log -1 --pretty=%s)
+if [[ "$HEAD_SUBJECT" =~ \(([0-9]+\.[0-9]+\.[0-9]+)\) ]]; then
+ die "HEAD commit subject contains inline version bump: \"$HEAD_SUBJECT\"
+Release contract: version bumps MUST live in a dedicated 'chore(release): X.Y.Z' commit.
+Revert the inline bump and re-run this script — it will create the proper commit."
+fi
+if [[ "$HEAD_SUBJECT" =~ ^chore\(release\): ]]; then
+ die "HEAD is already a chore(release) commit: \"$HEAD_SUBJECT\"
+Nothing new to release. Add commits since the last release or amend intentionally outside this script."
+fi
+
# ── Resolve version ────────────────────────────────────────────────
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
LATEST_VERSION="${LATEST_TAG#v}"
diff --git a/scripts/ship.sh b/scripts/ship.sh
new file mode 100755
index 0000000..d81fd6f
--- /dev/null
+++ b/scripts/ship.sh
@@ -0,0 +1,222 @@
+#!/usr/bin/env bash
+#
+# ship.sh — End-to-end CLI release pipeline.
+#
+# Standalone backup for when GitHub Actions is unavailable (org shadow-ban,
+# CI outage, etc). Mirrors what release.yml + docker job in CI would do.
+#
+# Pre-requisites:
+# - scripts/release.sh already ran → version.go bumped + tag created locally
+# - SENTRY_DSN exported (Sentry disabled in build if missing)
+# - docker logged in to docker.io as the org user
+# - SSH key for Hetzner publishing (see publish-cli-release.sh)
+#
+# Pipeline:
+# 1. Sanity: clean tree, tag at HEAD, version.go matches
+# 2. goreleaser build (skip GH publish — produces dist/*)
+# 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh
+# 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub
+# 5. Smoke checks (torrentclaw.com/version + docker run image version)
+# 6. Prune Forgejo releases older than FORGEJO_PRUNE_DAYS (default 90)
+# 7. Optional `git push --follow-tags`
+#
+# Usage:
+# scripts/ship.sh Detect version from internal/cmd/version.go
+# scripts/ship.sh 0.9.12 Explicit version
+# scripts/ship.sh --dry-run Preview steps, no side effects
+# scripts/ship.sh --push 0.9.12 Also git-push tag to GH afterwards
+#
+# Env knobs:
+# SENTRY_DSN telemetry DSN injected at build time
+# RELEASE_SIGNING_PUBKEY ed25519 pubkey (base64) for self-update signature check
+# DOCKER_IMAGE default torrentclaw/unarr
+# PUBLISH_SCRIPT default ../torrentclaw-web/scripts/publish-cli-release.sh
+# SKIP_DOCKER=1 skip Docker build/push
+# SKIP_HETZNER=1 skip Hetzner publish
+# SKIP_SMOKE=1 skip smoke checks
+# SKIP_FORGEJO_PRUNE=1 skip Forgejo retention prune
+# FORGEJO_TOKEN PAT with write:repository for prune (no token = skip + warn)
+# FORGEJO_PRUNE_DAYS retention window, default 90 days
+# FORGEJO_REPO default torrentclaw/unarr
+#
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$REPO_DIR"
+
+DOCKER_IMAGE="${DOCKER_IMAGE:-torrentclaw/unarr}"
+PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-cli-release.sh}"
+SKIP_DOCKER="${SKIP_DOCKER:-0}"
+SKIP_HETZNER="${SKIP_HETZNER:-0}"
+SKIP_SMOKE="${SKIP_SMOKE:-0}"
+SKIP_FORGEJO_PRUNE="${SKIP_FORGEJO_PRUNE:-0}"
+FORGEJO_PRUNE_DAYS="${FORGEJO_PRUNE_DAYS:-90}"
+FORGEJO_REPO="${FORGEJO_REPO:-torrentclaw/unarr}"
+FORGEJO_BASE="${FORGEJO_BASE:-https://git.torrentclaw.com}"
+
+DRY_RUN=false
+PUSH_TAG=false
+VERSION=""
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
+info() { echo -e "${CYAN}▸${NC} $*"; }
+ok() { echo -e "${GREEN}✓${NC} $*"; }
+warn() { echo -e "${YELLOW}⚠${NC} $*"; }
+die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; }
+
+for a in "$@"; do
+ case "$a" in
+ --dry-run) DRY_RUN=true ;;
+ --push) PUSH_TAG=true ;;
+ -h|--help)
+ sed -n '2,/^set /p' "$0" | sed 's/^#\s\?//;$d'
+ exit 0 ;;
+ [0-9]*) VERSION="$a" ;;
+ *) die "unknown arg: $a (use --help)" ;;
+ esac
+done
+
+read_version_go() {
+ grep 'var Version' internal/cmd/version.go | sed 's/.*"\(.*\)".*/\1/'
+}
+
+REPO_VERSION="$(read_version_go)"
+[ -z "$VERSION" ] && VERSION="$REPO_VERSION"
+[ -n "$VERSION" ] || die "cannot detect version (pass explicit X.Y.Z)"
+TAG="v$VERSION"
+MINOR="${VERSION%.*}"
+
+echo ""
+echo -e " ${BOLD}Ship Plan${NC}"
+echo -e " ─────────────────────────────"
+echo -e " Version: ${GREEN}$TAG${NC}"
+echo -e " Docker image: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}"
+echo -e " Skip Hetzner: $SKIP_HETZNER"
+echo -e " Skip Docker: $SKIP_DOCKER"
+echo -e " Push to GH: $PUSH_TAG"
+echo -e " Dry run: $DRY_RUN"
+echo ""
+
+# Sanity
+[ "$REPO_VERSION" = "$VERSION" ] || die "version.go=$REPO_VERSION ≠ requested $VERSION (bump with make release-* first)"
+
+if [ "$DRY_RUN" = false ]; then
+ [ -z "$(git status --porcelain)" ] || die "working tree dirty"
+ git rev-parse "$TAG" >/dev/null 2>&1 || die "tag $TAG missing — run scripts/release.sh first"
+
+ HEAD_SHA="$(git rev-parse HEAD)"
+ TAG_SHA="$(git rev-parse "$TAG^{commit}")"
+ [ "$HEAD_SHA" = "$TAG_SHA" ] || die "HEAD ($HEAD_SHA) ≠ tag commit ($TAG_SHA) — checkout $TAG first"
+
+ command -v goreleaser >/dev/null || die "goreleaser not installed"
+ [ "$SKIP_DOCKER" = "1" ] || command -v docker >/dev/null || die "docker not installed"
+ [ "$SKIP_HETZNER" = "1" ] || [ -x "$PUBLISH_SCRIPT" ] || die "publish script missing or not executable: $PUBLISH_SCRIPT"
+
+ if [ -z "${SENTRY_DSN:-}" ]; then
+ warn "SENTRY_DSN unset — built binaries will have Sentry disabled"
+ fi
+fi
+
+if [ "$DRY_RUN" = true ]; then
+ ok "Dry run complete — no changes made"
+ exit 0
+fi
+
+# 1. Build
+info "goreleaser build ($TAG)"
+SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_PUBKEY="${RELEASE_SIGNING_PUBKEY:-}" \
+ goreleaser release --clean --skip=publish
+ok "dist/ ready"
+
+# 2. Hetzner
+if [ "$SKIP_HETZNER" != "1" ]; then
+ info "publishing to Hetzner releases volume"
+ "$PUBLISH_SCRIPT" "$VERSION"
+ ok "Hetzner version.txt flipped to $VERSION"
+fi
+
+# 3. Docker
+if [ "$SKIP_DOCKER" != "1" ]; then
+ info "docker buildx multi-arch push ($DOCKER_IMAGE:$VERSION, :$MINOR, :latest)"
+ docker buildx build \
+ --platform linux/amd64,linux/arm64 \
+ --build-arg VERSION="$TAG" \
+ -t "$DOCKER_IMAGE:$VERSION" \
+ -t "$DOCKER_IMAGE:$MINOR" \
+ -t "$DOCKER_IMAGE:latest" \
+ --push .
+ ok "Docker Hub: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}"
+fi
+
+# 4. Smoke
+if [ "$SKIP_SMOKE" != "1" ]; then
+ info "smoke checks"
+ if [ "$SKIP_HETZNER" != "1" ]; then
+ LIVE_VERSION="$(curl -fsSL https://torrentclaw.com/version 2>/dev/null | tr -d '[:space:]' || echo '')"
+ if [ "$LIVE_VERSION" = "$VERSION" ]; then
+ ok "torrentclaw.com/version = $LIVE_VERSION"
+ else
+ warn "torrentclaw.com/version = '$LIVE_VERSION' (expected $VERSION)"
+ fi
+ fi
+
+ if [ "$SKIP_DOCKER" != "1" ]; then
+ DOCKER_VERSION="$(docker run --rm "$DOCKER_IMAGE:$VERSION" version 2>/dev/null | grep -oE 'v[0-9.]+' | head -1)"
+ if [ "$DOCKER_VERSION" = "$TAG" ]; then
+ ok "docker image $DOCKER_IMAGE:$VERSION reports $DOCKER_VERSION"
+ else
+ warn "docker image reports '$DOCKER_VERSION' (expected $TAG)"
+ fi
+ fi
+fi
+
+# 6. Forgejo retention prune
+if [ "$SKIP_FORGEJO_PRUNE" != "1" ]; then
+ if [ -z "${FORGEJO_TOKEN:-}" ]; then
+ warn "FORGEJO_TOKEN not set — skipping Forgejo prune (set it to enable >${FORGEJO_PRUNE_DAYS}-day cleanup)"
+ else
+ info "pruning Forgejo releases older than $FORGEJO_PRUNE_DAYS days"
+ FORGEJO_API="$FORGEJO_BASE/api/v1/repos/$FORGEJO_REPO/releases"
+ RELEASES_JSON="$(curl -fsSL -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API?limit=50" || echo '[]')"
+ PRUNE_IDS="$(echo "$RELEASES_JSON" | python3 -c "
+import json, sys
+from datetime import datetime, timedelta, timezone
+days = int('${FORGEJO_PRUNE_DAYS}')
+cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+for r in json.load(sys.stdin):
+ created = datetime.fromisoformat(r['created_at'].replace('Z', '+00:00'))
+ if created < cutoff:
+ print(f\"{r['id']}\t{r['tag_name']}\t{r['created_at']}\")
+" 2>/dev/null || true)"
+ DELETED=0
+ FAILED=0
+ if [ -n "$PRUNE_IDS" ]; then
+ while IFS=$'\t' read -r REL_ID REL_TAG REL_CREATED; do
+ [ -z "$REL_ID" ] && continue
+ CODE="$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API/$REL_ID")"
+ if [ "$CODE" = "204" ]; then
+ echo " deleted $REL_TAG (created $REL_CREATED)"
+ DELETED=$((DELETED + 1))
+ else
+ warn " failed to delete $REL_TAG (id=$REL_ID, http=$CODE)"
+ FAILED=$((FAILED + 1))
+ fi
+ done <<< "$PRUNE_IDS"
+ fi
+ if [ "$FAILED" -gt 0 ]; then
+ warn "Forgejo prune: $DELETED removed, $FAILED failed"
+ else
+ ok "Forgejo prune: $DELETED release(s) removed (>${FORGEJO_PRUNE_DAYS} days old)"
+ fi
+ fi
+fi
+
+# 7. Optional push
+if [ "$PUSH_TAG" = true ]; then
+ info "git push origin main --follow-tags"
+ git push origin main --follow-tags
+ ok "tag $TAG pushed to GitHub"
+fi
+
+echo ""
+ok "${BOLD}$TAG shipped${NC}"