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 de1dd6e..38118cb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,131 +5,87 @@ 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
-
-
-### Added
-
-- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
-- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
-
-### Changed
-
-- **hls**: critico-driven hardening of fase 3.2
-
-### Fixed
-
-- **cors**: allow play from .to / staging / onion mirrors
-- **library**: classify resolution by width + height, not height alone
-- **transcode**: make preset libx264-only + restore quality opt-in
-
-### Other
-
-- **release**: 0.9.11
-## [0.9.8] - 2026-05-27
-
-
-### Fixed
-
-- **upgrade**: break auto-apply restart loop (0.9.8)
-## [0.9.7] - 2026-05-26
-
-
-### Added
-
-- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
-## [0.9.6] - 2026-05-26
-
-
-### Added
-
-- **daemon**: auto-apply upgrades when server signals (0.9.6)
## [0.9.5] - 2026-05-26
-
### Added
-- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
-## [0.9.4] - 2026-05-26
-
-
-### Added
-
-- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
-## [0.9.3] - 2026-05-26
-
-
-### Added
-
-- **usenet**: warn at startup when par2 or extractor is missing
+- **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
@@ -140,10 +96,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
@@ -153,10 +105,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
@@ -170,8 +118,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
@@ -545,17 +491,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.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..4129ffb 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.
@@ -343,58 +343,6 @@ unarr self-update --force # reinstall even if up to date
`unarr doctor` checks: config file, API key, server connectivity (with latency), agent registration, download directory, disk space, and version.
-### Updating unarr
-
-unarr supports three update paths. Pick whichever fits your workflow.
-
-**1. Manual self-update (always available).**
-
-```bash
-unarr self-update # interactive update to latest
-unarr self-update --force # reinstall same version
-unarr self-update --allow-unsigned # accept releases without checksum signature
-```
-
-The CLI downloads the new release archive over HTTPS (from
-`torrentclaw.com/releases/download/v/`), verifies SHA-256, swaps the
-binary in place (`.backup` kept next to it), and restarts the systemd
-user unit if the daemon is running.
-
-**2. Auto-apply on server signal (default, since 0.9.6).**
-
-When you press **"Force update now"** on the web (Settings → Agent → Force
-update), the server sets a flag your daemon polls every sync (~3 s). On
-the next sync the daemon downloads the new binary, replaces itself, and
-exits — `systemd Restart=always` respawns on the new version. No SSH, no
-terminal access required. Works headless on NAS / Docker.
-
-The button shows an amber warning if your agent is below 0.9.6 (older
-daemons see the signal but only log "run unarr update" — the operator
-must run the command manually that one time).
-
-**Opt out of auto-apply.** Some users prefer reviewing CHANGELOG before
-applying. Disable in `config.toml`:
-
-```toml
-[daemon]
-auto_upgrade = false
-```
-
-With `auto_upgrade = false`, pressing the web button still flags your
-agent (so the daemon logs the new version on next sync), but the daemon
-will not download / replace anything — you run `unarr self-update` when
-you're ready.
-
-**3. Docker auto-restart with a new tag.**
-
-```bash
-docker pull torrentclaw/unarr:latest
-docker compose up -d
-```
-
-Tags published: `latest`, `0.9`, `0.9.7`, ... — pin to a minor (`0.9`)
-for opt-in patch updates without surprises.
-
## Clean
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting.
@@ -476,7 +424,6 @@ tv_shows_dir = "~/Media/TV Shows"
[daemon]
poll_interval = "30s"
heartbeat_interval = "30s"
-auto_upgrade = true # apply server-flagged upgrades in-place (since 0.9.6)
[notifications]
enabled = true
@@ -519,40 +466,6 @@ If `transcode.enabled = true` but `ffmpeg` / `ffprobe` aren't on PATH, the
daemon logs a warning at startup and HLS sessions are rejected at runtime
with a clear error — install ffmpeg or set `enabled = false`.
-#### `[downloads.hls_cache]` — persistent HLS segment cache
-
-```toml
-[downloads.hls_cache]
-enabled = true # on by default
-size_gb = 5 # disk budget; LRU eviction once exceeded
-dir = "" # custom path; empty = ~/.cache/unarr/hls-cache
-```
-
-| Key | Type | Default | Notes |
-|-----|------|---------|-------|
-| `enabled` | bool | `true` | Persists finished HLS encodes per `(source, quality, audio_index)`. A second play of the same file at the same quality reuses the segments — no ffmpeg, near-zero CPU, instant playback. Set to `false` to delete segments on session close (original behavior). |
-| `size_gb` | int | `5` | Cache budget in gigabytes. When exceeded the LRU sweeper evicts the least-recently-used cached encodes hourly. Minimum 1 GB (smaller values are clamped up). |
-| `dir` | string | `""` | Custom storage path. Empty defaults to `~/.cache/unarr/hls-cache` (Linux/macOS) or the user cache dir (Windows). |
-
-**What it does.** First play encodes normally (ffmpeg writes segments).
-On session close, if every segment is on disk and ffmpeg exited cleanly,
-the directory is sealed with a `.complete` marker and kept. Next time the
-same source + quality combo is requested, the daemon serves segments
-straight from disk — no transcode, no warm-up, no CPU cost.
-
-**Why per (source, quality, audio).** Renaming the file or switching
-quality invalidates the entry: the segments are tied to the exact source
-bytes and the exact ffmpeg parameters. Re-encoding generates a new key.
-
-**Eviction.** A background goroutine wakes every hour. If total cache size
-exceeds `size_gb`, it deletes the oldest entries (by mtime) until under
-budget. Active sessions are pinned — they never get evicted mid-play.
-
-**Disable.** Either edit the TOML to set `enabled = false`, or remove the
-cache directory manually (it'll be recreated as needed). Disabling does
-not delete existing cached segments — drop `dir` (or `~/.cache/unarr/hls-cache`)
-to reclaim the space.
-
#### `[downloads.vpn]`
| Key | Type | Default | Notes |
diff --git a/internal/agent/client.go b/internal/agent/client.go
index e7f2c37..9aa3c2a 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -91,45 +91,6 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
return nil
}
-// ReportUpgradeResult tells the server the outcome of a previously requested
-// upgrade so the server can clear `upgrade_requested`. Without this call the
-// flag stays sticky and the daemon would re-trigger applyAutoUpgrade on every
-// sync after upgrade — even for "already on target version" no-ops.
-func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, success bool, version, errMsg string) error {
- req := struct {
- AgentID string `json:"agentId"`
- Success bool `json:"success"`
- Version string `json:"version,omitempty"`
- Error string `json:"error,omitempty"`
- }{AgentID: agentID, Success: success, Version: version, Error: errMsg}
- var resp StatusResponse
- if err := c.doPost(ctx, "/api/internal/agent/upgrade-result", req, &resp); err != nil {
- return fmt.Errorf("report upgrade result: %w", err)
- }
- return nil
-}
-
-// MarkSessionReady signals the server that the first HLS segment + init.mp4
-// landed on disk for the given session. The web side flips
-// streaming_session.ready_at = NOW(), which its SSE endpoint emits to
-// subscribed players so the "Preparando…" UI ends without polling HEAD
-// on /hls//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..109dfbb 100644
--- a/internal/agent/daemon.go
+++ b/internal/agent/daemon.go
@@ -11,8 +11,6 @@ import (
"strings"
"sync/atomic"
"time"
-
- "github.com/torrentclaw/unarr/internal/upgrade"
)
// DaemonConfig holds daemon runtime settings.
@@ -28,15 +26,6 @@ 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)
}
// Daemon manages agent registration and the sync loop.
@@ -130,10 +119,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,
@@ -246,16 +231,10 @@ func (d *Daemon) Run(ctx context.Context) error {
}
}
d.sync.OnUpgrade = func(version string) {
- if version == d.lastNotifiedVersion {
- return
+ if version != d.lastNotifiedVersion {
+ d.lastNotifiedVersion = version
+ log.Printf("New version available: %s (run `unarr self-update` to upgrade)", version)
}
- d.lastNotifiedVersion = version
- if !d.cfg.AutoUpgrade {
- log.Printf("[upgrade] new version available: %s — auto_upgrade=false, run `unarr update` to apply", version)
- return
- }
- log.Printf("[upgrade] new version available: %s — applying auto-upgrade", version)
- go d.applyAutoUpgrade(version)
}
d.sync.OnScan = func() {
log.Printf("Library scan requested by server")
@@ -302,67 +281,6 @@ func (d *Daemon) Deregister() {
RemoveState()
}
-// applyAutoUpgrade downloads the target version and exits so the service
-// supervisor (systemd Restart=always on Linux) respawns on the new binary.
-// Triggered by the server's upgrade signal — opt-in flag set by the user from
-// the web UI; the daemon never auto-upgrades on a passive version bump.
-//
-// Reports the outcome to /api/internal/agent/upgrade-result so the server
-// clears `upgrade_requested`. Without this report the flag stays sticky and
-// the daemon would loop on every sync — including the no-op case where it's
-// already on the target version.
-func (d *Daemon) applyAutoUpgrade(targetVersion string) {
- currentClean := strings.TrimPrefix(d.cfg.Version, "v")
- targetClean := strings.TrimPrefix(targetVersion, "v")
-
- // No-op: server signal arrived but we're already running the target. This
- // happens when the daemon restarts after a previous auto-upgrade before
- // reportUpgradeResult cleared the flag, or when the operator manually
- // installed the same version off-band. Skip Execute (which would also
- // no-op) AND skip os.Exit, but DO clear the flag — otherwise we loop.
- if currentClean == targetClean {
- log.Printf("[upgrade] already on v%s — clearing server flag", currentClean)
- ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancelR()
- if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, currentClean, ""); err != nil {
- log.Printf("[upgrade] report-result failed (will retry on next signal): %v", err)
- }
- return
- }
-
- upgrader := &upgrade.Upgrader{
- CurrentVersion: currentClean,
- OnProgress: func(msg string) {
- log.Printf("[upgrade] %s", msg)
- },
- }
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
- defer cancel()
- result := upgrader.Execute(ctx, targetVersion)
- if !result.Success {
- log.Printf("[upgrade] auto-upgrade failed: %v", result.Error)
- errMsg := ""
- if result.Error != nil {
- errMsg = result.Error.Error()
- }
- ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancelR()
- if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, false, targetClean, errMsg); err != nil {
- log.Printf("[upgrade] report-result failed: %v", err)
- }
- return
- }
- log.Printf("[upgrade] upgraded v%s → v%s; reporting result + exiting so service supervisor restarts on new binary",
- result.OldVersion, result.NewVersion)
- ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
- if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, result.NewVersion, ""); err != nil {
- log.Printf("[upgrade] report-result failed: %v", err)
- }
- cancelR()
- time.Sleep(500 * time.Millisecond)
- os.Exit(0)
-}
-
// isTransientError returns true for errors worth retrying (429, 5xx, network).
func isTransientError(err error) bool {
if err == nil {
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 2e0c074..6c00e95 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,11 +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(),
}
// Create HTTP client with mirror failover so a `.com` block-out rolls
@@ -309,47 +292,13 @@ 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.
if err := engine.CleanupHLSOrphanDirs(); err != nil {
log.Printf("[hls] orphan tmpdir cleanup: %v", err)
}
-
- // Persistent HLS segment cache — survives across sessions so re-plays
- // of the same file at the same quality skip ffmpeg entirely. Off when
- // hls_cache.enabled = false; size cap from hls_cache.size_gb; path from
- // hls_cache.dir (defaults to ~/.cache/unarr/hls-cache).
- var hlsCache *engine.HLSCache
- if cfg.Download.HLSCache.Enabled {
- cacheDir := cfg.Download.HLSCache.Dir
- if cacheDir == "" {
- if base, err := os.UserCacheDir(); err == nil {
- cacheDir = filepath.Join(base, "unarr", "hls-cache")
- } else {
- cacheDir = filepath.Join(os.TempDir(), "unarr-hls-cache")
- }
- }
- c, err := engine.NewHLSCache(cacheDir, cfg.Download.HLSCache.SizeGB)
- if err != nil {
- log.Printf("[hls_cache] init failed (%v) — falling back to per-session tmpdirs", err)
- } else {
- hlsCache = c
- hlsCache.StartSweeper(ctx, time.Hour)
- log.Printf("[hls_cache] enabled: dir=%s budget=%dGB", cacheDir, cfg.Download.HLSCache.SizeGB)
- }
- } else {
- log.Printf("[hls_cache] disabled by config — every play re-encodes from scratch")
- }
if err := streamSrv.Listen(ctx); err != nil {
return fmt.Errorf("start stream server: %w", err)
}
@@ -594,30 +543,15 @@ func runDaemonStart() error {
Quality: sess.Quality,
AudioIndex: sess.AudioIndex,
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)
@@ -900,93 +834,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/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 194e3c0..3009c5a 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.15"
+var Version = "0.9.5"
diff --git a/internal/config/config.go b/internal/config/config.go
index dd406a6..12573b4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -52,22 +52,10 @@ type DownloadConfig struct {
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
Transcode TranscodeConfig `toml:"transcode"`
- HLSCache HLSCacheConfig `toml:"hls_cache"`
VPN VPNConfig `toml:"vpn"`
Funnel FunnelConfig `toml:"funnel"`
}
-// HLSCacheConfig controls the persistent HLS segment cache. A completed encode
-// is kept on disk so a second play of the same file at the same quality skips
-// ffmpeg entirely. Old entries are evicted (LRU) once the cache exceeds the
-// size budget. Enabled by default — disable to save disk space at the cost of
-// re-encoding every play.
-type HLSCacheConfig struct {
- Enabled bool `toml:"enabled"` // default: true
- SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1
- Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache
-}
-
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
// daemon's HLS server over a public HTTPS hostname (https://.try
// cloudflare.com). Enabling it lets the web player on torrentclaw.com play
@@ -96,27 +84,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)
@@ -131,27 +101,8 @@ type OrganizeConfig struct {
type DaemonConfig struct {
StatusInterval string `toml:"status_interval"`
- // AutoUpgrade gates the daemon's response to a server-flagged upgrade
- // (set via the "Force update" button on the web). When true the daemon
- // downloads + replaces the binary in-place and exits so the service
- // supervisor respawns on the new version. When false the daemon only
- // logs "new version available" and the operator must run `unarr update`
- // manually. Default: true. Available since unarr 0.9.6.
- AutoUpgrade *bool `toml:"auto_upgrade"`
}
-// AutoUpgradeEnabled returns the resolved AutoUpgrade flag — defaults to true
-// when the user has not set it explicitly. Pointer-vs-bool because Go's
-// zero-value bool would collapse "unset" and "false" together.
-func (d DaemonConfig) AutoUpgradeEnabled() bool {
- if d.AutoUpgrade == nil {
- return true
- }
- return *d.AutoUpgrade
-}
-
-func boolPtr(v bool) *bool { return &v }
-
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
@@ -194,10 +145,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,
},
@@ -208,24 +156,11 @@ func Default() Config {
// `unarr funnel off` (sets enabled=false in the TOML).
Enabled: true,
},
- HLSCache: HLSCacheConfig{
- // On by default — second play of a recently watched file at the
- // same quality skips ffmpeg (instant start, near-zero CPU).
- // Users can opt out (hls_cache.enabled=false) or shrink the
- // budget (hls_cache.size_gb) when disk is tight.
- Enabled: true,
- SizeGB: 5,
- },
- },
- Daemon: DaemonConfig{
- // Pointer-to-true so Default() round-trips through TOML marshal
- // as `auto_upgrade = true` instead of an omitted key — keeps the
- // freshly-written config aligned with what README documents.
- AutoUpgrade: boolPtr(true),
},
Organize: OrganizeConfig{
Enabled: true,
},
+ Daemon: DaemonConfig{},
Notifications: NotificationsConfig{
Enabled: true,
},
@@ -301,12 +236,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 86219d5..9524627 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.
@@ -136,11 +100,6 @@ type HLSSessionConfig struct {
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
Transcode TranscodeRuntime
- // Cache is an optional persistent segment cache keyed by (source, quality,
- // audio). When set, completed encodes are kept across sessions so re-plays
- // of the same file at the same quality skip ffmpeg entirely. nil disables
- // caching (per-session tmpdir, deleted on Close — original behavior).
- Cache *HLSCache
}
// HLSSession owns a tmpdir + ffmpeg subprocess producing HLS fragments.
@@ -172,29 +131,14 @@ 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
-
- // Persistent cache state. cache==nil means caching disabled for this session.
- // fromCache=true means the session is replaying a completed encode and no
- // ffmpeg subprocess was spawned. writerLockHeld=true means this session
- // owns the per-key TryAcquireWriter claim — Close must ReleaseWriter.
- // subsDone closes when the subtitle extractor goroutine returns (or is
- // nil when the source had no subtitle tracks); MarkComplete waits on it
- // so a HIT replay never serves partial .vtt files.
- cache *HLSCache
- cacheKey string
- fromCache bool
- writerLockHeld bool
- subsDone chan struct{}
}
// hlsSeekAhead is how many segments past the writer's current position the
@@ -319,78 +263,18 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
return nil, errors.New("hls: source has no duration")
}
- // Resolve tmpDir + cache placement. Three states:
- // 1. cache disabled → per-session tmpdir, deleted on Close.
- // 2. cache HIT (.complete found) → read from cache dir, no ffmpeg, Pin.
- // 3. cache MISS, writer-lock OK → ffmpeg writes to cache dir, Pin + writer-lock.
- // 4. cache MISS, writer-lock NO → another session already writing this
- // key; fall back to private per-session tmpdir
- // (no caching for this session — second-writer
- // would corrupt the first one's segments).
- var (
- tmpDir string
- cacheKey string
- fromCache bool
- writerLockHeld bool
- )
- if cfg.Cache != nil {
- cacheKey = cfg.Cache.KeyFor(cfg.SourcePath, cfg.Quality, cfg.AudioIndex)
- // 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)
- 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)
- _ = cfg.Cache.Invalidate(cacheKey)
- }
- if cfg.Cache.HasComplete(cacheKey) {
- // HIT: read-only replay — many concurrent HITs are fine.
- tmpDir = cfg.Cache.DirFor(cacheKey)
- cfg.Cache.Pin(cacheKey)
- fromCache = true
- cfg.Cache.RecordHit()
- _ = cfg.Cache.Touch(cacheKey)
- } else if cfg.Cache.TryAcquireWriter(cacheKey) {
- tmpDir = cfg.Cache.DirFor(cacheKey)
- cfg.Cache.Pin(cacheKey)
- writerLockHeld = true
- cfg.Cache.RecordMiss()
- } else {
- // Another session is writing this key — fall back to private
- // dir so we don't trample its segments.
- log.Printf("[hls %s] cache key %s busy, falling back to per-session tmpdir",
- shortHLSID(cfg.SessionID), cacheKey)
- tmpDir = filepath.Join(hlsTmpDirRoot(), cfg.SessionID)
- cacheKey = "" // disable caching for this session
- cfg.Cache.RecordMiss()
- }
- } else {
- tmpDir = filepath.Join(hlsTmpDirRoot(), cfg.SessionID)
- }
-
- cleanupOnError := func() {
- if cfg.Cache != nil && cacheKey != "" {
- cfg.Cache.Unpin(cacheKey)
- if writerLockHeld {
- cfg.Cache.ReleaseWriter(cacheKey)
- _ = cfg.Cache.Invalidate(cacheKey)
- }
- } else {
- _ = os.RemoveAll(tmpDir)
- }
- }
-
+ tmpDir := filepath.Join(hlsTmpDirRoot(), cfg.SessionID)
if err := os.MkdirAll(filepath.Join(tmpDir, "video"), 0o755); err != nil {
- cleanupOnError()
return nil, fmt.Errorf("hls: mkdir video: %w", err)
}
if err := os.MkdirAll(filepath.Join(tmpDir, "subs"), 0o755); err != nil {
- cleanupOnError()
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,
@@ -401,30 +285,10 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
startedAt: time.Now(),
lastTouch: time.Now(),
readyCh: make(chan struct{}),
- cache: cfg.Cache,
- cacheKey: cacheKey,
- fromCache: fromCache,
- writerLockHeld: writerLockHeld,
}
s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
- // Cache HIT: every segment + init.mp4 is already on disk. Skip ffmpeg
- // entirely and mark readyMax so handlers don't wait. Background subtitle
- // extraction is also unnecessary — subs were extracted on the original run.
- if fromCache {
- s.readyMu.Lock()
- s.readyMax = segCount - 1
- s.exited = true
- close(s.readyCh)
- s.readyCh = nil
- s.readyMu.Unlock()
- log.Printf("[hls %s] cache HIT %s: %s, %.1fs, %d segs (quality=%s)",
- shortHLSID(cfg.SessionID), cacheKey, filepath.Base(cfg.SourcePath),
- probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"))
- return s, nil
- }
-
// Spawn ffmpeg under a dedicated context so Close() can kill it without
// touching the parent ctx.
ffCtx, cancel := context.WithCancel(context.Background())
@@ -434,7 +298,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
cmd.Stderr = &hlsStderrCapture{owner: s}
if err := cmd.Start(); err != nil {
cancel()
- cleanupOnError()
+ _ = os.RemoveAll(tmpDir)
return nil, fmt.Errorf("hls: start ffmpeg: %w", err)
}
s.cmd = cmd
@@ -443,30 +307,12 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
go s.pollSegments(ffCtx)
if len(probe.SubtitleTracks) > 0 {
- s.subsDone = make(chan struct{})
- go func() {
- defer close(s.subsDone)
- s.extractSubtitles(ffCtx)
- }()
+ go s.extractSubtitles(ffCtx)
}
- cachedNote := ""
- 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)",
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"))
return s, nil
}
@@ -519,28 +365,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 }
@@ -561,15 +385,8 @@ func (s *HLSSession) Touch() {
s.mu.Unlock()
}
-// Close stops ffmpeg and prevents further requests from blocking on segment
-// readiness. Idempotent.
-//
-// Disk lifecycle:
-// - cache disabled → delete tmpDir (original behavior).
-// - cache enabled + this session was a HIT → keep dir, just unpin.
-// - cache enabled + this was a write session → if ffmpeg exited cleanly and
-// every segment is on disk, persist with .complete and keep dir. Otherwise
-// drop the dir so a half-written cache doesn't survive into the next play.
+// Close stops ffmpeg, deletes the tmpdir, and prevents further requests from
+// blocking on segment readiness. Idempotent.
func (s *HLSSession) Close() error {
s.mu.Lock()
if s.closed {
@@ -590,47 +407,7 @@ func (s *HLSSession) Close() error {
s.readyCh = nil
}
s.exited = true
- exitErr := s.exitErr
s.readyMu.Unlock()
-
- if s.cache != nil && s.cacheKey != "" {
- defer s.cache.Unpin(s.cacheKey)
- if s.writerLockHeld {
- defer s.cache.ReleaseWriter(s.cacheKey)
- }
- if s.fromCache {
- log.Printf("[hls %s] closed (cache reuse)", shortHLSID(s.cfg.SessionID))
- return nil
- }
- // Wait briefly for the subtitle extractor to finish so a cached
- // replay never serves half-written .vtt files. Bounded so a stuck
- // extractor can't block Close indefinitely; on timeout we treat
- // the cache as incomplete and drop it.
- subsOK := true
- if s.subsDone != nil {
- select {
- case <-s.subsDone:
- case <-time.After(15 * time.Second):
- log.Printf("[hls %s] subtitle extractor timeout — not caching", shortHLSID(s.cfg.SessionID))
- subsOK = false
- }
- }
- if subsOK && exitErr == nil && s.allSegmentsPresent() {
- if err := s.cache.MarkComplete(s.cacheKey); err == nil {
- log.Printf("[hls %s] cache persisted %s", shortHLSID(s.cfg.SessionID), s.cacheKey)
- return nil
- } else {
- log.Printf("[hls %s] cache persist failed: %v", shortHLSID(s.cfg.SessionID), err)
- }
- }
- // Partial / failed → drop so we re-encode next time.
- if err := s.cache.Invalidate(s.cacheKey); err != nil {
- log.Printf("[hls %s] cache invalidate failed: %v", shortHLSID(s.cfg.SessionID), err)
- }
- log.Printf("[hls %s] closed (cache discarded)", shortHLSID(s.cfg.SessionID))
- return nil
- }
-
if tmpDir != "" {
_ = os.RemoveAll(tmpDir)
}
@@ -638,31 +415,6 @@ func (s *HLSSession) Close() error {
return nil
}
-// allSegmentsPresent reports whether every expected segment (and init.mp4) is
-// on disk AND validated by the segment poller. Used to decide whether a
-// finished session is cacheable. We trust readyMax (advanced by pollSegments
-// only after the next segment exists, proving the predecessor is fully closed)
-// over a naive Size>0 stat that could accept truncated mid-write files.
-func (s *HLSSession) allSegmentsPresent() bool {
- if fi, err := os.Stat(filepath.Join(s.tmpDir, "video", "init.mp4")); err != nil || fi.Size() == 0 {
- return false
- }
- s.readyMu.Lock()
- readyMax := s.readyMax
- s.readyMu.Unlock()
- if readyMax < s.segmentCount-1 {
- return false
- }
- for i := 0; i < s.segmentCount; i++ {
- path := filepath.Join(s.tmpDir, "video", fmt.Sprintf("seg-%d.m4s", i))
- fi, err := os.Stat(path)
- if err != nil || fi.Size() == 0 {
- return false
- }
- }
- return true
-}
-
// waitFFmpeg reaps the ffmpeg process and records its exit error for handlers.
//
// Auto-restart supervisor: if ffmpeg crashes (non-graceful exit) and the
@@ -963,10 +715,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 +781,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,54 +827,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 + tune=ll trades ~0.3 dB PSNR for 1.5-2× faster encode vs the
- // previous p4 + tune=hq pair — first-segment encode drops from
- // ~1.5 s to ~0.8 s on RTX-class hardware.
- args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-tune", "ll")
+ // 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)
@@ -1229,32 +895,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)
@@ -1327,10 +975,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")
@@ -1341,7 +985,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_cache.go b/internal/engine/hls_cache.go
deleted file mode 100644
index f1bf918..0000000
--- a/internal/engine/hls_cache.go
+++ /dev/null
@@ -1,410 +0,0 @@
-package engine
-
-import (
- "context"
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "fmt"
- "log"
- "os"
- "path/filepath"
- "sort"
- "sync"
- "sync/atomic"
- "time"
-)
-
-// HLSCache persists transcoded HLS segments per (source, quality, audio) so a
-// second play of the same file at the same quality skips ffmpeg entirely.
-//
-// Layout on disk:
-//
-// {root}/{key}/init.mp4
-// {root}/{key}/seg-0.m4s
-// {root}/{key}/seg-N.m4s
-// {root}/{key}/.complete
-//
-// Atomicity: the .complete marker is written only when ffmpeg exits 0 AND all
-// segments are on disk. A dir without .complete is treated as a partial run —
-// next session can reuse the segments already present, ffmpeg fills the gaps.
-//
-// Concurrency: Pin/Unpin increments a ref counter per key so the LRU sweeper
-// never evicts a directory that an active session is reading from.
-type HLSCache struct {
- root string
- maxBytes int64
-
- mu sync.Mutex
- refs map[string]int
- writers map[string]bool // exclusive ffmpeg writer per key; nil entries are absent
-
- // Counters surfaced via Stats() — useful for /api/internal/agent/cache-stats
- // and for the sweeper's daily log line. atomic so RecordHit/RecordMiss are
- // safe to call from any goroutine without taking the cache mutex.
- hits atomic.Uint64
- misses atomic.Uint64
-}
-
-const (
- hlsCacheCompleteMarker = ".complete"
- // hlsCacheMinBudgetGB clamps absurd / zero / negative SizeGB values to
- // a sane floor. NOT a guarantee that any single encode fits — a long
- // 4K HEVC re-encode can exceed it. Operators should set size_gb based
- // on their actual workload.
- hlsCacheMinBudgetGB = 1
- // hlsCacheStartupOrphanAge: directories without .complete older than
- // this are removed on cache startup. Long enough that a daemon crash
- // during an in-progress encode (which legitimately leaves a partial
- // dir) doesn't get nuked too aggressively if the daemon restarts fast.
- hlsCacheStartupOrphanAge = 10 * time.Minute
-)
-
-// NewHLSCache creates the cache rooted at the given dir with a size budget in
-// gigabytes. A budget < hlsCacheMinBudgetGB is clamped up so a single play
-// doesn't get instantly evicted mid-stream.
-func NewHLSCache(root string, sizeGB int) (*HLSCache, error) {
- if root == "" {
- return nil, errors.New("hls_cache: empty root")
- }
- if sizeGB < hlsCacheMinBudgetGB {
- sizeGB = hlsCacheMinBudgetGB
- }
- if err := os.MkdirAll(root, 0o755); err != nil {
- return nil, fmt.Errorf("hls_cache: mkdir root: %w", err)
- }
- c := &HLSCache{
- root: root,
- maxBytes: int64(sizeGB) * 1024 * 1024 * 1024,
- refs: make(map[string]int),
- writers: make(map[string]bool),
- }
- // Reap dirs left over from a crashed encode. A dir without .complete that
- // hasn't been touched recently was almost certainly orphaned by an
- // ungraceful daemon exit — keeping it just feeds the unbounded growth
- // pattern the hourly LRU is too slow to contain.
- if removed, err := c.cleanStartupOrphans(); err != nil {
- log.Printf("[hls_cache] startup orphan cleanup: %v", err)
- } else if removed > 0 {
- log.Printf("[hls_cache] startup: removed %d orphan dir(s) without .complete", removed)
- }
- return c, nil
-}
-
-// cleanStartupOrphans removes cache subdirectories that lack a .complete
-// marker AND haven't been modified within hlsCacheStartupOrphanAge. Called
-// once at construction. Safe at startup because no sessions are active yet,
-// so Pin can't race with us.
-func (c *HLSCache) cleanStartupOrphans() (int, error) {
- entries, err := os.ReadDir(c.root)
- if err != nil {
- if os.IsNotExist(err) {
- return 0, nil
- }
- return 0, err
- }
- cutoff := time.Now().Add(-hlsCacheStartupOrphanAge)
- removed := 0
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- dir := filepath.Join(c.root, e.Name())
- if _, err := os.Stat(filepath.Join(dir, hlsCacheCompleteMarker)); err == nil {
- continue // sealed, keep
- }
- info, err := e.Info()
- if err != nil {
- continue
- }
- if info.ModTime().After(cutoff) {
- continue // too recent — might be a daemon that just restarted mid-encode
- }
- if err := os.RemoveAll(dir); err == nil {
- removed++
- }
- }
- return removed, nil
-}
-
-// TryAcquireWriter attempts to claim exclusive ffmpeg-write access to a key.
-// Returns true on success — the caller is then responsible for ReleaseWriter
-// when ffmpeg exits / fails. Returns false if another session is already
-// writing this key, in which case the caller must fall back to a private
-// per-session tmpdir (no caching for that session).
-func (c *HLSCache) TryAcquireWriter(key string) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- if c.writers[key] {
- return false
- }
- c.writers[key] = true
- return true
-}
-
-// ReleaseWriter releases the writer claim acquired via TryAcquireWriter.
-// Idempotent on unknown keys.
-func (c *HLSCache) ReleaseWriter(key string) {
- c.mu.Lock()
- delete(c.writers, key)
- c.mu.Unlock()
-}
-
-// KeyFor derives a stable cache key for (source, quality, audioIndex). Using
-// the absolute source path means renaming a file invalidates the cache, which
-// is correct — segment content is tied to the encoded source.
-func (c *HLSCache) KeyFor(sourcePath, quality string, audioIndex int) string {
- abs, err := filepath.Abs(sourcePath)
- if err != nil {
- abs = sourcePath
- }
- h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", abs, quality, audioIndex)))
- return hex.EncodeToString(h[:8]) // 16 hex chars — collision-safe enough for per-host cache
-}
-
-// DirFor returns the on-disk directory for a cache key. Caller is responsible
-// for creating it.
-func (c *HLSCache) DirFor(key string) string {
- return filepath.Join(c.root, key)
-}
-
-// HasComplete returns true when the .complete marker is present, meaning the
-// directory holds a full set of segments from a successful encode.
-func (c *HLSCache) HasComplete(key string) bool {
- if _, err := os.Stat(filepath.Join(c.DirFor(key), hlsCacheCompleteMarker)); err == nil {
- return true
- }
- return false
-}
-
-// MarkComplete writes the .complete marker. Call only after verifying ffmpeg
-// exited cleanly AND every expected segment is on disk. The dir must already
-// exist — StartHLSSession created it on the writer path.
-func (c *HLSCache) MarkComplete(key string) error {
- return os.WriteFile(filepath.Join(c.DirFor(key), hlsCacheCompleteMarker), nil, 0o644)
-}
-
-// RecordHit increments the hit counter; called by StartHLSSession on a
-// cache-HIT path.
-func (c *HLSCache) RecordHit() { c.hits.Add(1) }
-
-// RecordMiss increments the miss counter; called when a session has to
-// encode from scratch (or fails an integrity check on a stale HIT).
-func (c *HLSCache) RecordMiss() { c.misses.Add(1) }
-
-// CacheStats is a snapshot of the cache's runtime counters + on-disk size.
-// The size fields are best-effort (computed via dirSize) so callers paying
-// for them should cache the result, not poll in a hot loop.
-type CacheStats struct {
- Hits uint64
- Misses uint64
- EntryCount int
- TotalBytes int64
-}
-
-// Stats returns a snapshot of the cache counters and size. Walks the root
-// to total disk usage — O(N segments). Call at most every few minutes.
-func (c *HLSCache) Stats() CacheStats {
- s := CacheStats{
- Hits: c.hits.Load(),
- Misses: c.misses.Load(),
- }
- entries, err := os.ReadDir(c.root)
- if err != nil {
- return s
- }
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- size, err := dirSize(filepath.Join(c.root, e.Name()))
- if err != nil {
- continue
- }
- s.EntryCount++
- s.TotalBytes += size
- }
- return s
-}
-
-// hitRatePercent returns the current hit/(hit+miss) percentage rounded to
-// the nearest int; 0 when no calls have been recorded.
-func (c *HLSCache) hitRatePercent() int {
- h := c.hits.Load()
- m := c.misses.Load()
- total := h + m
- if total == 0 {
- return 0
- }
- return int((h*100 + total/2) / total)
-}
-
-// VerifyComplete checks that the .complete marker is present AND the
-// essential files (init.mp4 + last segment) exist with non-zero size. A
-// dir that passes HasComplete but fails VerifyComplete is treated as
-// corrupted — typically external `rm` or a partial-disk-failure scenario.
-// When it returns false, callers should Invalidate and re-encode.
-func (c *HLSCache) VerifyComplete(key string, segmentCount int) bool {
- if !c.HasComplete(key) {
- return false
- }
- dir := c.DirFor(key)
- if fi, err := os.Stat(filepath.Join(dir, "video", "init.mp4")); err != nil || fi.Size() == 0 {
- return false
- }
- if segmentCount > 0 {
- lastSeg := filepath.Join(dir, "video", fmt.Sprintf("seg-%d.m4s", segmentCount-1))
- if fi, err := os.Stat(lastSeg); err != nil || fi.Size() == 0 {
- return false
- }
- }
- return true
-}
-
-// Pin increments the ref counter for a key. The sweeper checks this before
-// evicting, so a pinned dir is safe even if its mtime is old.
-func (c *HLSCache) Pin(key string) {
- c.mu.Lock()
- c.refs[key]++
- c.mu.Unlock()
-}
-
-// Unpin decrements; safe to call on unknown keys (no-op).
-func (c *HLSCache) Unpin(key string) {
- c.mu.Lock()
- if c.refs[key] > 0 {
- c.refs[key]--
- if c.refs[key] == 0 {
- delete(c.refs, key)
- }
- }
- c.mu.Unlock()
-}
-
-func (c *HLSCache) isPinned(key string) bool {
- c.mu.Lock()
- defer c.mu.Unlock()
- return c.refs[key] > 0
-}
-
-// Touch updates the directory mtime so LRU picks fresher entries as recently
-// used. Called when a session starts reading from a cached dir.
-func (c *HLSCache) Touch(key string) error {
- dir := c.DirFor(key)
- now := time.Now()
- return os.Chtimes(dir, now, now)
-}
-
-// Sweep enforces the size budget by deleting the least-recently-used cache
-// dirs (ignoring pinned ones) until the total size is at or below maxBytes.
-// Returns the number of bytes freed.
-func (c *HLSCache) Sweep() (int64, error) {
- entries, err := os.ReadDir(c.root)
- if err != nil {
- if os.IsNotExist(err) {
- return 0, nil
- }
- return 0, fmt.Errorf("hls_cache: read root: %w", err)
- }
-
- type item struct {
- key string
- path string
- size int64
- mtime time.Time
- }
- items := make([]item, 0, len(entries))
- var total, pinned int64
- for _, e := range entries {
- if !e.IsDir() {
- continue
- }
- info, err := e.Info()
- if err != nil {
- continue
- }
- key := e.Name()
- path := filepath.Join(c.root, key)
- size, err := dirSize(path)
- if err != nil {
- continue
- }
- items = append(items, item{key: key, path: path, size: size, mtime: info.ModTime()})
- total += size
- if c.isPinned(key) {
- pinned += size
- }
- }
-
- if total <= c.maxBytes {
- return 0, nil
- }
- if pinned >= c.maxBytes {
- // Every pinned byte already exceeds the budget — even evicting
- // every unpinned dir won't bring us under. Warn loudly so the
- // operator knows to bump size_gb (or kill the long-running session).
- log.Printf("[hls_cache] warn: pinned bytes (%.1f MB) exceed budget (%.1f MB) — cannot enforce limit until sessions release",
- float64(pinned)/(1024*1024), float64(c.maxBytes)/(1024*1024))
- return 0, nil
- }
-
- // Oldest first.
- sort.Slice(items, func(i, j int) bool {
- return items[i].mtime.Before(items[j].mtime)
- })
-
- var freed int64
- for _, it := range items {
- if total-freed <= c.maxBytes {
- break
- }
- if c.isPinned(it.key) {
- continue
- }
- if err := os.RemoveAll(it.path); err != nil {
- log.Printf("[hls_cache] evict %s failed: %v", it.key, err)
- continue
- }
- log.Printf("[hls_cache] evicted %s (%.1f MB, age %s)",
- it.key, float64(it.size)/(1024*1024), time.Since(it.mtime).Round(time.Second))
- freed += it.size
- }
- return freed, nil
-}
-
-// StartSweeper kicks off the LRU sweeper goroutine. Cancels on ctx done.
-// In addition to enforcing the size budget, logs a daily summary of hit-rate
-// + disk usage so operators can see the cache's value at a glance.
-func (c *HLSCache) StartSweeper(ctx context.Context, interval time.Duration) {
- if interval <= 0 {
- interval = time.Hour
- }
- go func() {
- t := time.NewTicker(interval)
- defer t.Stop()
- statsTick := time.NewTicker(24 * time.Hour)
- defer statsTick.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-t.C:
- if _, err := c.Sweep(); err != nil {
- log.Printf("[hls_cache] sweep error: %v", err)
- }
- case <-statsTick.C:
- s := c.Stats()
- log.Printf("[hls_cache] day-stats: hits=%d misses=%d ratio=%d%% entries=%d size=%.1fMB",
- s.Hits, s.Misses, c.hitRatePercent(), s.EntryCount,
- float64(s.TotalBytes)/(1024*1024))
- }
- }
- }()
-}
-
-// Invalidate removes a cache entry — used when ffmpeg fails to encode the
-// source so we don't reuse a half-written dir next time.
-func (c *HLSCache) Invalidate(key string) error {
- return os.RemoveAll(c.DirFor(key))
-}
-
diff --git a/internal/engine/hls_cache_smoke_test.go b/internal/engine/hls_cache_smoke_test.go
deleted file mode 100644
index a086cb2..0000000
--- a/internal/engine/hls_cache_smoke_test.go
+++ /dev/null
@@ -1,134 +0,0 @@
-//go:build smoke
-
-package engine
-
-import (
- "context"
- "os/exec"
- "path/filepath"
- "testing"
- "time"
-)
-
-// TestHLSCacheSmoke exercises the end-to-end cache flow against real ffmpeg:
-// - First session encodes a 5s test pattern; expect MISS, ffmpeg runs,
-// .complete written, MarkComplete logs.
-// - Second session for identical (source, quality, audio); expect HIT,
-// no ffmpeg, instant Start.
-//
-// Build tag `smoke` keeps it out of the default `go test ./...` run because
-// it depends on a working ffmpeg/ffprobe and takes ~5–10 s.
-//
-// go test -tags=smoke -run TestHLSCacheSmoke -v ./internal/engine/
-func TestHLSCacheSmoke(t *testing.T) {
- ffmpeg, err := exec.LookPath("ffmpeg")
- if err != nil {
- t.Skipf("ffmpeg not on PATH: %v", err)
- }
- ffprobe, err := exec.LookPath("ffprobe")
- if err != nil {
- t.Skipf("ffprobe not on PATH: %v", err)
- }
-
- tmp := t.TempDir()
- source := filepath.Join(tmp, "source.mp4")
- t.Logf("generating 5 s test pattern → %s", source)
- if out, err := exec.Command(ffmpeg,
- "-y", "-loglevel", "error",
- "-f", "lavfi", "-i", "testsrc=duration=5:size=640x480:rate=30",
- "-f", "lavfi", "-i", "sine=frequency=1000:duration=5",
- "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
- "-c:a", "aac",
- source,
- ).CombinedOutput(); err != nil {
- t.Fatalf("ffmpeg generate: %v\n%s", err, out)
- }
-
- cacheRoot := filepath.Join(tmp, "cache")
- cache, err := NewHLSCache(cacheRoot, 1)
- if err != nil {
- t.Fatalf("NewHLSCache: %v", err)
- }
-
- cfg := HLSSessionConfig{
- SessionID: "smoke1",
- SourcePath: source,
- FileName: "source.mp4",
- Quality: "720p",
- AudioIndex: 0,
- Transcode: TranscodeRuntime{
- FFmpegPath: ffmpeg,
- FFprobePath: ffprobe,
- Preset: "ultrafast",
- },
- Cache: cache,
- }
-
- // First run — expect MISS, ffmpeg runs.
- t.Log("session 1: expect MISS")
- t0 := time.Now()
- s1, err := StartHLSSession(context.Background(), cfg)
- if err != nil {
- t.Fatalf("StartHLSSession #1: %v", err)
- }
- if s1.fromCache {
- t.Fatal("session 1 reported cache HIT on a fresh cache")
- }
-
- // Wait for all segments to land. 5 s source @ 4 s segments → 2 segments.
- deadline := time.Now().Add(60 * time.Second)
- for {
- s1.readyMu.Lock()
- ready := s1.readyMax
- exited := s1.exited
- s1.readyMu.Unlock()
- if ready >= s1.segmentCount-1 && exited {
- break
- }
- if time.Now().After(deadline) {
- _ = s1.Close()
- t.Fatalf("session 1 didn't finish in 60 s (readyMax=%d/%d, exited=%v)",
- ready, s1.segmentCount-1, exited)
- }
- time.Sleep(100 * time.Millisecond)
- }
- if err := s1.Close(); err != nil {
- t.Fatalf("Close #1: %v", err)
- }
- encodeDur := time.Since(t0)
- t.Logf("session 1: MISS completed in %s", encodeDur.Round(time.Millisecond))
-
- key := cache.KeyFor(source, "720p", 0)
- if !cache.HasComplete(key) {
- t.Fatalf("cache.HasComplete(%s) is false after successful encode", key)
- }
-
- // Second run — expect HIT, no ffmpeg.
- t.Log("session 2: expect HIT")
- cfg.SessionID = "smoke2"
- t1 := time.Now()
- s2, err := StartHLSSession(context.Background(), cfg)
- if err != nil {
- t.Fatalf("StartHLSSession #2: %v", err)
- }
- if !s2.fromCache {
- t.Fatal("session 2 should have reported cache HIT")
- }
- if s2.cmd != nil {
- t.Fatal("session 2 should not have spawned ffmpeg (s.cmd != nil)")
- }
- hitDur := time.Since(t1)
- t.Logf("session 2: HIT in %s (%.1f× faster than MISS)",
- hitDur.Round(time.Millisecond), float64(encodeDur)/float64(hitDur))
- if hitDur > 500*time.Millisecond {
- t.Errorf("HIT path too slow: %s — expected <500 ms", hitDur)
- }
- if err := s2.Close(); err != nil {
- t.Fatalf("Close #2: %v", err)
- }
-
- // After the HIT session closes, the cache dir + .complete must still exist.
- if !cache.HasComplete(key) {
- t.Fatal(".complete disappeared after HIT session closed")
- }
-}
diff --git a/internal/engine/hls_cache_test.go b/internal/engine/hls_cache_test.go
deleted file mode 100644
index cb70ec1..0000000
--- a/internal/engine/hls_cache_test.go
+++ /dev/null
@@ -1,361 +0,0 @@
-package engine
-
-import (
- "context"
- "os"
- "path/filepath"
- "sync"
- "testing"
- "time"
-)
-
-func newTestCache(t *testing.T, sizeGB int) *HLSCache {
- t.Helper()
- root := t.TempDir()
- c, err := NewHLSCache(root, sizeGB)
- if err != nil {
- t.Fatalf("NewHLSCache: %v", err)
- }
- return c
-}
-
-func TestKeyForStable(t *testing.T) {
- c := newTestCache(t, 1)
- k1 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
- k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
- if k1 != k2 {
- t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
- }
- if c.KeyFor("/a/b/movie.mkv", "720p", 0) == k1 {
- t.Fatal("quality should change key")
- }
- if c.KeyFor("/a/b/movie.mkv", "1080p", 1) == k1 {
- t.Fatal("audio index should change key")
- }
- if c.KeyFor("/x/y/other.mkv", "1080p", 0) == k1 {
- t.Fatal("path should change key")
- }
-}
-
-func TestMarkCompleteAndHas(t *testing.T) {
- c := newTestCache(t, 1)
- key := "abc123"
- if c.HasComplete(key) {
- t.Fatal("fresh cache should not report complete")
- }
- // Production callers create the dir during StartHLSSession; MarkComplete
- // trusts that invariant and fails if the dir was wiped meanwhile.
- if err := os.MkdirAll(c.DirFor(key), 0o755); err != nil {
- t.Fatalf("mkdir: %v", err)
- }
- if err := c.MarkComplete(key); err != nil {
- t.Fatalf("MarkComplete: %v", err)
- }
- if !c.HasComplete(key) {
- t.Fatal("after MarkComplete, HasComplete must be true")
- }
-}
-
-func TestMarkCompleteFailsWithoutDir(t *testing.T) {
- c := newTestCache(t, 1)
- if err := c.MarkComplete("never-created"); err == nil {
- t.Fatal("MarkComplete should error when dir doesn't exist")
- }
-}
-
-func TestPinPreventsEviction(t *testing.T) {
- c := newTestCache(t, 1) // 1 GB budget, but min clamp keeps it usable
- c.maxBytes = 1024 // squeeze budget for the test
-
- // Write two entries past the budget.
- for i, key := range []string{"old", "new"} {
- dir := c.DirFor(key)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- t.Fatalf("mkdir %s: %v", dir, err)
- }
- path := filepath.Join(dir, "seg.bin")
- if err := os.WriteFile(path, make([]byte, 800), 0o644); err != nil {
- t.Fatalf("write %s: %v", path, err)
- }
- now := time.Now().Add(time.Duration(i) * time.Hour) // "old" mtime < "new"
- _ = os.Chtimes(dir, now, now)
- }
-
- c.Pin("old") // protect the older one
- freed, err := c.Sweep()
- if err != nil {
- t.Fatalf("Sweep: %v", err)
- }
- if freed == 0 {
- t.Fatal("expected some eviction")
- }
- if _, err := os.Stat(c.DirFor("old")); err != nil {
- t.Fatal("pinned 'old' was evicted")
- }
- if _, err := os.Stat(c.DirFor("new")); err == nil {
- t.Fatal("'new' should have been evicted to make room")
- }
-}
-
-func TestSweepNoOpUnderBudget(t *testing.T) {
- c := newTestCache(t, 1)
- dir := c.DirFor("small")
- _ = os.MkdirAll(dir, 0o755)
- _ = os.WriteFile(filepath.Join(dir, "x"), []byte("tiny"), 0o644)
- freed, err := c.Sweep()
- if err != nil {
- t.Fatalf("Sweep: %v", err)
- }
- if freed != 0 {
- t.Fatalf("expected 0 freed under budget, got %d", freed)
- }
- if _, err := os.Stat(dir); err != nil {
- t.Fatal("under-budget entry was wrongly evicted")
- }
-}
-
-func TestSweepEmptyRoot(t *testing.T) {
- c := newTestCache(t, 1)
- freed, err := c.Sweep()
- if err != nil {
- t.Fatalf("Sweep empty: %v", err)
- }
- if freed != 0 {
- t.Fatalf("freed=%d, want 0", freed)
- }
-}
-
-func TestInvalidateRemovesDir(t *testing.T) {
- c := newTestCache(t, 1)
- key := "drop"
- dir := c.DirFor(key)
- _ = os.MkdirAll(dir, 0o755)
- _ = os.WriteFile(filepath.Join(dir, "x"), []byte("y"), 0o644)
- if err := c.Invalidate(key); err != nil {
- t.Fatalf("Invalidate: %v", err)
- }
- if _, err := os.Stat(dir); err == nil {
- t.Fatal("dir still present after Invalidate")
- }
-}
-
-func TestTouchUpdatesMtime(t *testing.T) {
- c := newTestCache(t, 1)
- key := "touch"
- dir := c.DirFor(key)
- _ = os.MkdirAll(dir, 0o755)
- old := time.Now().Add(-2 * time.Hour)
- _ = os.Chtimes(dir, old, old)
-
- if err := c.Touch(key); err != nil {
- t.Fatalf("Touch: %v", err)
- }
- info, err := os.Stat(dir)
- if err != nil {
- t.Fatalf("stat: %v", err)
- }
- if !info.ModTime().After(old.Add(time.Minute)) {
- t.Fatalf("mtime not refreshed: %v", info.ModTime())
- }
-}
-
-func TestPinUnpinSymmetry(t *testing.T) {
- c := newTestCache(t, 1)
- c.Pin("k")
- c.Pin("k")
- if !c.isPinned("k") {
- t.Fatal("Pin twice should leave pinned")
- }
- c.Unpin("k")
- if !c.isPinned("k") {
- t.Fatal("Unpin once should keep pinned (refs=1)")
- }
- c.Unpin("k")
- if c.isPinned("k") {
- t.Fatal("Unpin twice should drop pin")
- }
- c.Unpin("k") // safe no-op
-}
-
-func TestConcurrentPinUnpin(t *testing.T) {
- c := newTestCache(t, 1)
- var wg sync.WaitGroup
- for i := 0; i < 100; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- c.Pin("race")
- time.Sleep(time.Microsecond)
- c.Unpin("race")
- }()
- }
- wg.Wait()
- if c.isPinned("race") {
- t.Fatal("refs leaked")
- }
-}
-
-func TestSweeperLoopExits(t *testing.T) {
- c := newTestCache(t, 1)
- ctx, cancel := context.WithCancel(context.Background())
- c.StartSweeper(ctx, 10*time.Millisecond)
- time.Sleep(30 * time.Millisecond)
- cancel()
- // If StartSweeper doesn't exit on cancel the test would leak a goroutine;
- // the leak detector in the test runner will surface it.
- time.Sleep(20 * time.Millisecond)
-}
-
-func TestMinBudgetClamp(t *testing.T) {
- root := t.TempDir()
- c, err := NewHLSCache(root, 0) // below floor
- if err != nil {
- t.Fatalf("NewHLSCache: %v", err)
- }
- if c.maxBytes != int64(hlsCacheMinBudgetGB)*1024*1024*1024 {
- t.Fatalf("budget not clamped to min: got %d", c.maxBytes)
- }
-}
-
-func TestTryAcquireWriterExclusive(t *testing.T) {
- c := newTestCache(t, 1)
- if !c.TryAcquireWriter("k") {
- t.Fatal("first acquire should succeed")
- }
- if c.TryAcquireWriter("k") {
- t.Fatal("second acquire for same key must fail")
- }
- if !c.TryAcquireWriter("other") {
- t.Fatal("different key should not conflict")
- }
- c.ReleaseWriter("k")
- if !c.TryAcquireWriter("k") {
- t.Fatal("acquire after release should succeed")
- }
- c.ReleaseWriter("k")
- c.ReleaseWriter("k") // idempotent
-}
-
-func TestStartupOrphanCleanup(t *testing.T) {
- root := t.TempDir()
-
- // Pre-seed: one sealed dir + one orphan old enough + one orphan fresh.
- sealed := filepath.Join(root, "sealed")
- _ = os.MkdirAll(sealed, 0o755)
- _ = os.WriteFile(filepath.Join(sealed, hlsCacheCompleteMarker), nil, 0o644)
-
- staleOrphan := filepath.Join(root, "stale_orphan")
- _ = os.MkdirAll(staleOrphan, 0o755)
- old := time.Now().Add(-2 * hlsCacheStartupOrphanAge)
- _ = os.Chtimes(staleOrphan, old, old)
-
- freshOrphan := filepath.Join(root, "fresh_orphan")
- _ = os.MkdirAll(freshOrphan, 0o755)
-
- if _, err := NewHLSCache(root, 1); err != nil {
- t.Fatalf("NewHLSCache: %v", err)
- }
-
- if _, err := os.Stat(sealed); err != nil {
- t.Fatal("sealed dir was wrongly removed")
- }
- if _, err := os.Stat(staleOrphan); err == nil {
- t.Fatal("stale orphan should have been removed at startup")
- }
- if _, err := os.Stat(freshOrphan); err != nil {
- t.Fatal("fresh orphan should be kept (might be a mid-restart encode)")
- }
-}
-
-func TestHitMissCounters(t *testing.T) {
- c := newTestCache(t, 1)
- if s := c.Stats(); s.Hits != 0 || s.Misses != 0 {
- t.Fatalf("fresh cache stats not zero: %+v", s)
- }
- c.RecordHit()
- c.RecordHit()
- c.RecordMiss()
- s := c.Stats()
- if s.Hits != 2 || s.Misses != 1 {
- t.Fatalf("counters wrong: %+v", s)
- }
- // 2/3 = 67%
- if got := c.hitRatePercent(); got != 67 {
- t.Fatalf("hitRatePercent=%d, want 67", got)
- }
-}
-
-func TestStatsEntryCount(t *testing.T) {
- c := newTestCache(t, 1)
- for _, k := range []string{"a", "b", "c"} {
- dir := c.DirFor(k)
- _ = os.MkdirAll(dir, 0o755)
- _ = os.WriteFile(filepath.Join(dir, "x"), []byte("hello"), 0o644)
- }
- s := c.Stats()
- if s.EntryCount != 3 {
- t.Fatalf("EntryCount=%d, want 3", s.EntryCount)
- }
- if s.TotalBytes != 15 {
- t.Fatalf("TotalBytes=%d, want 15", s.TotalBytes)
- }
-}
-
-func TestVerifyCompleteRejectsMissingFiles(t *testing.T) {
- c := newTestCache(t, 1)
- key := "v"
- dir := c.DirFor(key)
- _ = os.MkdirAll(filepath.Join(dir, "video"), 0o755)
-
- // No .complete yet → reject.
- if c.VerifyComplete(key, 2) {
- t.Fatal("VerifyComplete should reject without .complete")
- }
-
- // Mark complete but no files → reject.
- if err := c.MarkComplete(key); err != nil {
- t.Fatalf("MarkComplete: %v", err)
- }
- if c.VerifyComplete(key, 2) {
- t.Fatal("VerifyComplete should reject when init.mp4 missing")
- }
-
- // Write init.mp4, last seg missing → reject.
- _ = os.WriteFile(filepath.Join(dir, "video", "init.mp4"), []byte("..."), 0o644)
- if c.VerifyComplete(key, 2) {
- t.Fatal("VerifyComplete should reject when last segment missing")
- }
-
- // Write last seg → pass.
- _ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), []byte("..."), 0o644)
- if !c.VerifyComplete(key, 2) {
- t.Fatal("VerifyComplete should pass with all files present")
- }
-
- // Zero-size last seg → reject.
- _ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), nil, 0o644)
- if c.VerifyComplete(key, 2) {
- t.Fatal("VerifyComplete should reject zero-size last segment")
- }
-}
-
-func TestSweepRespectsPinnedExceedsBudget(t *testing.T) {
- c := newTestCache(t, 1)
- c.maxBytes = 256 // squeeze
-
- pinned := c.DirFor("pinned")
- _ = os.MkdirAll(pinned, 0o755)
- _ = os.WriteFile(filepath.Join(pinned, "x"), make([]byte, 1024), 0o644)
- c.Pin("pinned")
-
- freed, err := c.Sweep()
- if err != nil {
- t.Fatalf("Sweep: %v", err)
- }
- if freed != 0 {
- t.Fatalf("nothing should have been freed: got %d", freed)
- }
- if _, err := os.Stat(pinned); err != nil {
- t.Fatal("pinned dir wrongly removed despite over-budget pin")
- }
-}
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/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 (