diff --git a/.env.example b/.env.example
deleted file mode 100644
index 4091938..0000000
--- a/.env.example
+++ /dev/null
@@ -1,16 +0,0 @@
-# Copy this file to .env and fill in your values.
-# Then run: docker compose up -d
-
-# Your TorrentClaw API key (required).
-# Get it at: https://torrentclaw.com/settings/api-keys
-UNARR_API_KEY=tc_your_key_here
-
-# Absolute path to your media / downloads folder.
-# This is where finished movies and shows will be saved.
-DOWNLOAD_DIR=/home/youruser/Media
-
-# (Optional) Config directory — defaults to ./config next to this file.
-# CONFIG_DIR=/home/youruser/.config/unarr
-
-# (Optional) Timezone for logs.
-# TZ=Europe/Madrid
diff --git a/.forgejo/workflows/docker-rebuild.yml b/.forgejo/workflows/docker-rebuild.yml
deleted file mode 100644
index 34cc3d6..0000000
--- a/.forgejo/workflows/docker-rebuild.yml
+++ /dev/null
@@ -1,61 +0,0 @@
-# Rebuilds and re-pushes the `latest` image without a version bump so newly
-# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
-# tags are immutable and never touched here. Runs weekly and on demand.
-name: Docker rebuild
-
-on:
- schedule:
- # Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
- - cron: "17 4 * * 1"
- workflow_dispatch:
-
-jobs:
- rebuild:
- runs-on: docker
- container:
- image: docker.io/library/docker:27-cli
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Install build deps
- run: apk add --no-cache curl git bash
-
- - name: Install buildx
- run: |
- mkdir -p ~/.docker/cli-plugins
- curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
- -o ~/.docker/cli-plugins/docker-buildx
- chmod +x ~/.docker/cli-plugins/docker-buildx
-
- - name: Set up qemu
- run: docker run --rm --privileged tonistiigi/binfmt --install all
-
- # Stamp the binary with the most recent release tag (not "dev").
- - name: Resolve version
- id: ver
- run: |
- v=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)
- echo "version=$v" >> "$GITHUB_OUTPUT"
-
- - name: Login to Docker Hub
- env:
- DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
- DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
- run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
-
- - name: Build + push (refresh latest)
- env:
- VERSION: ${{ steps.ver.outputs.version }}
- run: |
- docker buildx create --name builder --use --driver docker-container
- # Refresh the floating tag only — never overwrite a versioned release.
- # Force a fresh base pull so apk upgrade picks up new patches.
- docker buildx build \
- --platform linux/amd64,linux/arm64 \
- --build-arg "VERSION=$VERSION" \
- --tag "torrentclaw/unarr:latest" \
- --no-cache \
- --push \
- .
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
deleted file mode 100644
index d757612..0000000
--- a/.forgejo/workflows/release.yml
+++ /dev/null
@@ -1,118 +0,0 @@
-name: Release
-
-on:
- push:
- tags:
- - "v*"
- workflow_dispatch:
-
-permissions:
- contents: write
-
-jobs:
- release:
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Install build deps (bash, curl, jq, ffmpeg fetch deps)
- run: |
- apt-get update
- apt-get install -y --no-install-recommends bash curl ca-certificates jq xz-utils unzip
-
- - name: Install goreleaser
- run: |
- curl -sSfL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz \
- | tar -xz -C /usr/local/bin goreleaser
-
- - name: Run goreleaser
- env:
- # Forgejo runner auto-injects GITHUB_TOKEN (a per-job, instance-scoped
- # token usable against the Forgejo REST API). goreleaser only accepts
- # one token; with both GITHUB_TOKEN + GITEA_TOKEN set it errors out
- # ("multiple tokens"). Unset GITHUB_TOKEN before invoking goreleaser so
- # it picks the Gitea code path + the gitea_urls block in .goreleaser.yml.
- GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
- # Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
- # accepts it and the resulting binary disables signature checks
- # (back-compat: pre-signing releases continue to update). Set
- # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
- # to turn verification on.
- RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
- run: |
- unset GITHUB_TOKEN
- goreleaser release --clean
-
- - name: Sign checksums.txt with ed25519
- if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
- env:
- RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
- RELEASE_TAG: ${{ github.ref_name }}
- FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # Tailscale IP — domain-agnostic; the runner shares the dokploy-network with
- # forgejo (hostname `forgejo`), so the in-cluster hostname is fastest, but the
- # Tailscale IP is the documented fallback.
- FORGEJO_API: http://forgejo:3000/api/v1
- REPO: torrentclaw/unarr
- run: |
- set -euo pipefail
- go run ./scripts/sign-checksums \
- -key "$RELEASE_SIGNING_KEY" \
- -in dist/checksums.txt \
- -out dist/checksums.txt.sig
-
- # Find the release ID for this tag, then upload the sig as an asset.
- rel_id=$(curl -sSf "$FORGEJO_API/repos/$REPO/releases/tags/$RELEASE_TAG" \
- -H "Authorization: token $FORGEJO_TOKEN" | jq -r '.id')
- curl -sSf -X POST \
- "$FORGEJO_API/repos/$REPO/releases/$rel_id/assets?name=checksums.txt.sig" \
- -H "Authorization: token $FORGEJO_TOKEN" \
- -F "attachment=@dist/checksums.txt.sig"
-
- docker:
- needs: release
- runs-on: docker
- container:
- # Docker-in-Docker capable image — buildx + qemu pre-installed.
- image: docker.io/library/docker:27-cli
- steps:
- - uses: actions/checkout@v4
-
- - name: Install buildx
- run: |
- apk add --no-cache curl
- mkdir -p ~/.docker/cli-plugins
- curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
- -o ~/.docker/cli-plugins/docker-buildx
- chmod +x ~/.docker/cli-plugins/docker-buildx
-
- - name: Login to Docker Hub
- env:
- DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
- DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
- run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
-
- - name: Set up qemu
- run: docker run --rm --privileged tonistiigi/binfmt --install all
-
- - name: Build + push multi-arch image
- env:
- VERSION: ${{ github.ref_name }}
- run: |
- set -euo pipefail
- VERSION_SEMVER="${VERSION#v}"
- MAJOR_MINOR="${VERSION_SEMVER%.*}"
- docker buildx create --name builder --use --driver docker-container
- docker buildx build \
- --platform linux/amd64,linux/arm64 \
- --build-arg "VERSION=$VERSION" \
- --tag "torrentclaw/unarr:$VERSION_SEMVER" \
- --tag "torrentclaw/unarr:$MAJOR_MINOR" \
- --tag "torrentclaw/unarr:latest" \
- --push \
- .
diff --git a/.forgejo/workflows/ci.yml b/.github/workflows/ci.yml
similarity index 61%
rename from .forgejo/workflows/ci.yml
rename to .github/workflows/ci.yml
index 82ee799..7dabcc4 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,26 +12,35 @@ permissions:
jobs:
test:
name: Test
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ go-version: ["1.25"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: ${{ matrix.go-version }}
- name: Run tests
run: go test -v -race -count=1 ./...
build:
name: Build
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: "1.25"
- name: Build
env:
@@ -41,30 +50,30 @@ jobs:
lint:
name: Lint
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - name: Install golangci-lint
- run: |
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \
- | sh -s -- -b /usr/local/bin v2.11.4
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: "1.25"
- name: Run golangci-lint
- run: golangci-lint run ./...
+ uses: golangci/golangci-lint-action@v9
+ with:
+ version: v2.11.4
coverage:
name: Coverage
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - name: Install python3
- run: apt-get update && apt-get install -y --no-install-recommends python3
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: "1.25"
- name: Run tests with coverage (all packages)
run: |
@@ -93,13 +102,24 @@ jobs:
print('OK: Coverage meets minimum threshold')
"
+ - name: Upload coverage to Codecov
+ uses: codecov/codecov-action@v6
+ with:
+ files: ./coverage.out
+ fail_ci_if_error: false
+ env:
+ CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
+
vet:
name: Vet
- runs-on: docker
- container:
- image: docker.io/library/golang:1.25
+ runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
+
+ - name: Set up Go
+ uses: actions/setup-go@v6
+ with:
+ go-version: "1.25"
- name: Run go vet
run: go vet ./...
diff --git a/.github/workflows/docker-rebuild.yml b/.github/workflows/docker-rebuild.yml
new file mode 100644
index 0000000..c1634f1
--- /dev/null
+++ b/.github/workflows/docker-rebuild.yml
@@ -0,0 +1,52 @@
+# Rebuilds and re-pushes the `latest` image without a version bump so newly
+# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
+# tags are immutable and never touched here. Runs weekly and on demand.
+name: Docker rebuild
+
+on:
+ schedule:
+ # Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
+ - cron: "17 4 * * 1"
+ workflow_dispatch:
+
+jobs:
+ rebuild:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ # Stamp the binary with the most recent release tag (not "dev").
+ - name: Resolve version
+ id: ver
+ run: echo "version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" >> "$GITHUB_OUTPUT"
+
+ - uses: docker/setup-qemu-action@v4
+ - uses: docker/setup-buildx-action@v4
+
+ - uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - uses: docker/build-push-action@v7
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64,linux/arm64
+ # Refresh the floating tag only — never overwrite a versioned release.
+ tags: torrentclaw/unarr:latest
+ build-args: |
+ VERSION=${{ steps.ver.outputs.version }}
+ # Force a fresh base pull so apk upgrade picks up new patches.
+ no-cache: true
+
+ - name: Scan image for fixable CVEs (gate)
+ uses: docker/scout-action@v1
+ with:
+ command: cves
+ image: torrentclaw/unarr:latest
+ only-severities: critical,high
+ only-fixed: true
+ exit-code: true
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
new file mode 100644
index 0000000..d0c683d
--- /dev/null
+++ b/.github/workflows/pages.yml
@@ -0,0 +1,52 @@
+name: Deploy install scripts to Pages
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - install.sh
+ - install.ps1
+ - CNAME
+ - .nojekyll
+ - .github/workflows/pages.yml
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/configure-pages@v5
+ - name: Stage install scripts
+ run: |
+ mkdir -p _site
+ cp install.sh install.ps1 _site/
+ [ -f CNAME ] && cp CNAME _site/
+ touch _site/.nojekyll
+ # Also index page (humans landing)
+ cat > _site/index.html <<'HTML'
+
+
unarr installer
+ unarr CLI installer
+ Linux/macOS: curl -fsSL https://unarr.torrentclaw.com/install.sh | sh
+ Windows: irm https://unarr.torrentclaw.com/install.ps1 | iex
+ Source: github.com/torrentclaw/unarr
+
+ HTML
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: _site
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..dcb49ce
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,210 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+
+ - uses: actions/setup-go@v6
+ with:
+ go-version-file: go.mod
+
+ - uses: goreleaser/goreleaser-action@v6
+ with:
+ version: "~> v2"
+ args: release --clean
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
+ # Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
+ # accepts it and the resulting binary disables signature checks
+ # (back-compat: pre-signing releases continue to update). Set
+ # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
+ # to turn verification on.
+ RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
+
+ - name: Sign checksums.txt with ed25519
+ # Reference secrets.X directly — step-level env defined in this same
+ # step is unreliable to read from this step's own if: expression.
+ if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
+ env:
+ RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
+ RELEASE_TAG: ${{ github.ref_name }}
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ go run ./scripts/sign-checksums \
+ -key "$RELEASE_SIGNING_KEY" \
+ -in dist/checksums.txt \
+ -out dist/checksums.txt.sig
+ gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber
+
+ docker:
+ needs: release
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Docker meta
+ id: meta
+ uses: docker/metadata-action@v6
+ with:
+ images: torrentclaw/unarr
+ tags: |
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=raw,value=latest
+
+ - uses: docker/setup-qemu-action@v4
+ - uses: docker/setup-buildx-action@v4
+
+ - uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - uses: docker/build-push-action@v7
+ with:
+ context: .
+ push: true
+ platforms: linux/amd64,linux/arm64
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ build-args: |
+ VERSION=${{ github.ref_name }}
+
+ # CVE gate. Fails the release on FIXABLE critical/high only — unfixed
+ # upstream ffmpeg codec CVEs are accepted (see SECURITY.md), so the
+ # codec noise does not block. Runs post-push (image already published);
+ # a failure here flags that a fixable CVE slipped through.
+ - name: Scan image for fixable CVEs (gate)
+ uses: docker/scout-action@v1
+ with:
+ command: cves
+ image: torrentclaw/unarr:latest
+ only-severities: critical,high
+ only-fixed: true
+ exit-code: true
+
+ # Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a
+ # description-API auth hiccup must not undo a successful image push.
+ - name: Update Docker Hub description
+ uses: peter-evans/dockerhub-description@v4
+ continue-on-error: true
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+ repository: torrentclaw/unarr
+ readme-filepath: ./DOCKERHUB.md
+ short-description: "unarr — the single binary that replaces your *arr stack"
+
+
+ virustotal:
+ needs: release
+ runs-on: ubuntu-latest
+ if: vars.VT_ENABLED == 'true'
+ steps:
+ - name: Get release tag
+ id: tag
+ run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
+
+ - name: Download release assets
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ mkdir -p assets
+ gh release download "${{ steps.tag.outputs.tag }}" \
+ --repo "${{ github.repository }}" \
+ --dir assets \
+ --pattern '*.tar.gz' \
+ --pattern '*.zip' \
+ --pattern 'checksums.txt'
+
+ - name: Scan assets with VirusTotal
+ env:
+ VT_API_KEY: ${{ secrets.VT_API_KEY }}
+ run: |
+ mkdir -p results
+ for file in assets/*; do
+ filename=$(basename "$file")
+ echo "Uploading $filename to VirusTotal..."
+
+ response=$(curl -s --request POST \
+ --url https://www.virustotal.com/api/v3/files \
+ --header "x-apikey: $VT_API_KEY" \
+ --form "file=@$file")
+
+ analysis_id=$(echo "$response" | jq -r '.data.id // empty')
+ if [ -z "$analysis_id" ]; then
+ echo "::warning::Failed to upload $filename: $response"
+ continue
+ fi
+
+ echo "$filename=$analysis_id" >> results/scans.txt
+ echo " Analysis ID: $analysis_id"
+
+ # Rate limit: VT free tier allows 4 req/min
+ sleep 16
+ done
+
+ - name: Wait for analysis completion
+ env:
+ VT_API_KEY: ${{ secrets.VT_API_KEY }}
+ run: |
+ echo "Waiting 60s for VirusTotal analysis to complete..."
+ sleep 60
+
+ vt_report="## 🛡️ VirusTotal Scan Results\n\n"
+ vt_report+="| File | Result | Link |\n"
+ vt_report+="|------|--------|------|\n"
+
+ while IFS='=' read -r filename analysis_id; do
+ result=$(curl -s --request GET \
+ --url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
+ --header "x-apikey: $VT_API_KEY")
+
+ malicious=$(echo "$result" | jq -r '.data.attributes.stats.malicious // 0')
+ undetected=$(echo "$result" | jq -r '.data.attributes.stats.undetected // 0')
+ sha256=$(echo "$result" | jq -r '.meta.file_info.sha256 // empty')
+
+ if [ "$malicious" = "0" ]; then
+ status="✅ Clean ($undetected engines)"
+ else
+ status="⚠️ $malicious detections"
+ fi
+
+ link="https://www.virustotal.com/gui/file/$sha256"
+ vt_report+="| \`$filename\` | $status | [View]($link) |\n"
+
+ sleep 16
+ done < results/scans.txt
+
+ echo -e "$vt_report" > results/report.md
+ cat results/report.md
+
+ - name: Append scan results to release notes
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ current_body=$(gh release view "${{ steps.tag.outputs.tag }}" \
+ --repo "${{ github.repository }}" \
+ --json body --jq '.body')
+
+ new_body="${current_body}
+
+ $(cat results/report.md)"
+
+ gh release edit "${{ steps.tag.outputs.tag }}" \
+ --repo "${{ github.repository }}" \
+ --notes "$new_body"
diff --git a/.gitignore b/.gitignore
index 8015bab..81f1284 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,7 +41,4 @@ dist-ffbinaries/
# Docker
tmp/
config/
-dist-ffbinaries/
-
-# Claude Code: keep entirely local, do not track
-.claude/
\ No newline at end of file
+dist-ffbinaries/
\ No newline at end of file
diff --git a/.goreleaser.yml b/.goreleaser.yml
index 08a604e..26ce802 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -26,10 +26,10 @@ builds:
- -s -w
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
- # The release-signing PUBLIC key is compiled in as the canonical default
- # in internal/upgrade/signature.go (it's public — committing it removes
- # the "empty env var → unsigned binary" footgun). No ldflag override:
- # every build bakes the same key and verifies checksums.txt.sig.
+ # Release-signing public key — verified by the self-updater against
+ # checksums.txt.sig. Empty when not configured; in that case
+ # signature verification is skipped and a warning is logged.
+ - -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
archives:
- formats: [tar.gz]
@@ -51,28 +51,6 @@ archives:
checksum:
name_template: "checksums.txt"
-# Sign checksums.txt with the release ed25519 private key → checksums.txt.sig,
-# verified by the self-updater against the compiled-in public key. Releases are
-# signed UNCONDITIONALLY: sign-checksums requires -key, so an unset/empty
-# RELEASE_SIGNING_KEY makes this step (and the whole `goreleaser release`) fail
-# rather than silently shipping an unsigned release. ship.sh sources the key
-# from ~/.config/unarr-release/signing.key (or the RELEASE_SIGNING_KEY env).
-signs:
- - id: checksums
- cmd: go
- args:
- - run
- - ./scripts/sign-checksums
- - -key
- - "{{ .Env.RELEASE_SIGNING_KEY }}"
- - -in
- - "${artifact}"
- - -out
- - "${signature}"
- signature: "${artifact}.sig"
- artifacts: checksum
- output: true
-
changelog:
sort: asc
filters:
@@ -81,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 c83efc2..ca49641 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,415 +5,104 @@ 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).
-## [1.1.3-beta] - 2026-06-11
-
-
-### Added
-
-- **daemon**: telemetría de salud continua + heartbeat de sesiones copy
-- **daemon**: lock de instancia única por config dir (flock)
-## [1.1.2-beta] - 2026-06-11
-
-
-### Added
-
-- **stream**: HLS-copy — reemplazo resiliente del remux progresivo
-
-### Fixed
-
-- **stream**: hallazgos de la revisión crítica del modo copy
-- **stream**: no copiar AAC multicanal en modo copy (WebKit lo rechaza igual)
-- **stream**: downmix estéreo en el audio re-encodeado del modo copy
-- **stream**: EXT-X-START=0 en el playlist copy mientras crece
-- **stream**: el modo copy ignora StartSec (offset EVENT rompe iOS nativo)
-
-### Other
-
-- **release**: 1.1.2-beta
-## [1.1.1-beta] - 2026-06-10
-
-
-### Added
-
-- **stream**: UPnP-map the HTTPS port for remote direct-TLS (best-effort)
-
-### Fixed
-
-- **stream**: iOS exige total concreto en el Content-Range del remux
-
-### Other
-
-- **release**: 1.1.1-beta
-## [1.1.0-beta] - 2026-06-10
-
-
-### Added
-
-- **hls**: full-GPU scale_cuda for NVENC SDR downscales
-
-### Fixed
-
-- **stream**: delay_moov en el remux para audio AAC con dts negativo
-- **stream**: no anunciar un total falso mientras el remux crece (loop de re-seek)
-
-### Other
-
-- **release**: 1.1.0-beta
-## [1.0.9-beta] - 2026-06-10
-
-
-### Changed
-
-- **daemon**: revisión crítica del reporte de errores de sesión
-
-### Fixed
-
-- **daemon**: reportar fallos de arranque de sesión a la web + scan en sesión única
-
-### Other
-
-- **release**: 1.0.9-beta
-## [1.0.8-beta] - 2026-06-10
-
-
-### Added
-
-- **hls**: resume-aware first spawn + capped-CRF/CQ rate control
-- **subtitles**: subtitle-fetch jobs vía sync + auto-fetch opcional en scan
-
-### Fixed
-
-- **hls**: forced-idr en NVENC/QSV — los segmentos ignoraban force_key_frames
-- **hls**: los prewarms ya no desalojan la sesión del espectador + trickplay 12x
-
-### Other
-
-- **release**: 1.0.8-beta
-## [1.0.7-beta] - 2026-06-08
-
-
-### Added
-
-- **subs**: resilient subtitle extraction — sidecars, charset, torrent/debrid
-
-### Other
-
-- **release**: 1.0.7-beta
-## [1.0.6-beta] - 2026-06-07
-
-
-### Added
-
-- **agent**: per-machine key handoff + revocation handling
-
-### Fixed
-
-- **agent**: only treat explicit 410/403 as revocation; honour --config
-
-### Other
-
-- **release**: 1.0.6-beta
-## [1.0.5-beta] - 2026-06-07
-
-
-### Added
-
-- **agent**: per-agent direct-TLS cert client + HTTPS listener wiring
-- **stream**: live transcode telemetry from ffmpeg speed=
-
-### Documentation
-
-- **docker**: explain why GPU Vulkan tonemap can't init in-container
-
-### Fixed
-
-- **docker**: derive bundled dep arch from dpkg, not TARGETARCH default
-- **torrent**: suppress noisy UPnP AddPortMapping warnings
-
-### Other
-
-- **release**: 1.0.5-beta
-## [1.0.4-beta] - 2026-06-04
-
-
-### Fixed
-
-- **stream**: self-heal host→container path skew in HLS + sidecar handlers
-
-### Other
-
-- **release**: 1.0.4-beta
-## [1.0.3-beta] - 2026-06-04
-
-
-### Fixed
-
-- **trickplay**: stop scan-time sprite generation from saturating the host
-
-### Other
-
-- **release**: 1.0.3-beta
-## [1.0.2-beta] - 2026-06-03
-
-
-### Added
-
-- **stream**: debrid passthrough for mode=stream tasks (external players)
-- **trickplay**: scan-time montage sprite for the web scrubber
-
-### Fixed
-
-- **release**: keep prerelease suffix in docker smoke-check version compare
-## [1.0.1-beta] - 2026-06-03
-
-
-### Added
-
-- **agent**: report isDocker so the web shows a docker pull command
-- **release**: sign release checksums (ed25519), enforce + bake pubkey
-
-### Fixed
-
-- **stream**: retry thumbnail extraction with output-seek on seek-index failure
-- **stream**: clamp out-of-range audio-track index to 0:a:0
-
-### Other
-
-- **release**: 1.0.1-beta
-## [1.0.0-beta] - 2026-06-03
-
-
-### Added
-
-- **agent**: event-driven uplink — sync on every state transition
-- **agent**: hybrid SSE downlink with long-poll fallback
-- **agent**: give the public API client mirror failover
-- **agent**: auto-resume interrupted downloads after a daemon restart
-- **docker**: glibc base with nvenc ffmpeg + par2/7z extractors
-- **downloads**: pre-flight free-disk guard before each download (hueco medio)
-- **library**: content fingerprint + path-resilient sync + stream self-heal
-- **library**: detect corrupt/incomplete files during scan
-- **seeding**: wire seed ratio/time lifecycle into the torrent daemon
-- **stream**: enable GPU libplacebo in prod image + gate to real GPU
-- **stream**: benchmark software encode ceiling at startup
-- **stream**: GPU HDR tonemap via libplacebo
-- **stream**: /speedtest endpoint for agent-path bandwidth probing
-- **stream**: cache scan-time thumbnail frames to the .unarr sidecar
-- **stream**: cache extracted subtitles to a hidden .unarr sidecar
-- **stream**: serve embedded text subtitles as on-demand WebVTT
-- **stream**: optional per-agent HTTPS listener with hot-reloadable cert
-- **stream**: burn bitmap (PGS/DVB) subtitles into the video via overlay
-- **stream**: bitrate-sized readahead for play-while-download
-- **stream**: on-demand frame thumbnails via /thumbnail (hueco medio)
-- **stream**: refresh expired debrid links mid-stream (hueco #2/2c)
-- **stream**: transcode debrid sources to HLS from a URL (hueco #2/2b)
-- **stream**: serve /stream from a debrid HTTPS link (hueco #2/2a)
-- **stream**: device-aware remux (HEVC/AV1 + non-aac audio) + TTFF timers
-- **stream**: progressive fMP4 remux source for /stream (hueco #3 / 3b-i)
-- **stream**: direct-play passthrough for browser-native files
-- **stream**: authenticate /stream and /hls with signed tokens
-- **transcode**: tonemap HDR sources to SDR (zscale-gated)
-
-### Documentation
-
-- **docker**: add docker-compose.yml for one-command setup
-- **roadmap**: close the realtime hueco + mark Tailscale-Funnel note stale
-- **roadmap**: mark unarr localized-route 404 fixed
-- **roadmap**: mark hueco #2 closed (2a+2b+2c)
-- **roadmap**: mark hueco #2/2b (HLS-from-URL) closed
-- **roadmap**: hueco #3 fully closed — 3d resolved as 3d-lite auto-downshift
-- **roadmap**: hueco #3 3c closed (capability negotiation) + TTFF diagnosis
-- **roadmap**: hueco #3 phase 3b closed (progressive fMP4 remux) + smoke
-- **roadmap**: 3b approach = progressive fMP4 remux via /stream
-- **roadmap**: hueco #3 3a smoke e2e passed + brand-isolation fix noted
-- **roadmap**: add hueco #4 (pre-transcode on download) design
-- **roadmap**: hueco #3 phase 3a closed (direct-play)
-- **roadmap**: design hueco #3 (device-profile + direct-play + ABR)
-- **roadmap**: design hueco #2 (debrid in the streaming path)
-
-### Fixed
-
-- **agent**: surface par2/install/NFS failures instead of degrading silently
-- **stream**: don't cache transient libplacebo probe timeouts
-- **stream**: functional libplacebo probe + benchmark hardening
-- **stream**: clean HLS segments — no B-frames, no scene-cut, CFR
-- **stream**: report stream failures via StreamError + retry transient stat
-- **stream**: honor client network-caching in the M3U playlist
-- **stream**: /critico review fixes for the sidecar cache
-- **stream**: derive H.264 level from frame macroblocks, not height
-- **stream**: derive H.264 level from frame macroblocks, not height
-- **stream**: allow unarr.app origins for /stream + /hls CORS
-
-### Other
-
-- **release**: 1.0.0-beta
-- **release**: 1.0.0-beta
-- bump version to 0.10.0 (direct-play floor; local build only, no publish)
-
-### Performance
-
-- **stream**: run the subtitle/thumbnail prewarm at idle I/O priority
-- **stream**: extract all text subtitles of a file in one ffmpeg pass
-## [0.9.19] - 2026-05-30
-
-
-### Fixed
-
-- **docker**: three streaming/reliability bugs found in live docker test
-
-### Other
-
-- **release**: 0.9.19
-## [0.9.18] - 2026-05-29
-
-
-### Fixed
-
-- **stream**: make completed torrent files readable (mmap creates 0000)
-
-### Other
-
-- **release**: 0.9.18
-## [0.9.17] - 2026-05-27
-
-
-### Added
-
-- **scripts**: prune Forgejo releases >90 days in ship.sh
-
-### Fixed
-
-- **hls**: drop nvenc -tune ll — kills hls segmentation, bump 0.9.17
-
-### Other
-
-- **release**: 0.9.17
-## [0.9.15] - 2026-05-27
-
-
-### Added
-
-- **sentry**: enhance error handling by skipping user input errors in CaptureError
-
-### Changed
-
-- **ci**: point Forgejo URLs at torrentclaw org (post-transfer)
-- **sentry**: decouple agent import via string-match, rename predicate
-
-### Documentation
-
-- **positioning**: reframe unarr around download/stream/transcode, drop misleading search-first wording
-
-### Fixed
-
-- **ci**: unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
-- **sentry**: skip "daemon not running" stop/reload errors
-
-### Other
-
-- **release**: 0.9.15
-- **scripts**: harden release.sh against double-release and inline version bumps
-- untrack .claude/ (private local config)
-## [0.9.14] - 2026-05-27
-
-
-### Added
-
-- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
-
-### CI/CD
-
-- port workflows from .github/ to .forgejo/ (Forgejo Actions)
-
-### Fixed
-
-- **daemon**: defensive IsClosed check in watchSessionReady poll loop
-- **daemon**: use parent ctx for MarkSessionReady so cancel propagates
-- **release**: move gitea_urls to top-level (goreleaser v2 schema)
-## [0.9.13] - 2026-05-27
-
-
-### Added
-
-- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13)
-- **agent**: send full transcoder diagnostic in register payload (0.9.12)
-
-### Fixed
-
-- **daemon**: defer probeCancel so a panic mid-diagnostic still releases ctx
-
-### Other
-
-- **release**: add ship.sh end-to-end pipeline as GH Actions backup
-- **skills**: add /publish slash command + allow .claude/ in git
-## [0.9.11] - 2026-05-27
-
-
-### Added
-
-- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
-- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
-
-### Changed
-
-- **hls**: critico-driven hardening of fase 3.2
-
-### Fixed
-
-- **cors**: allow play from .to / staging / onion mirrors
-- **library**: classify resolution by width + height, not height alone
-- **transcode**: make preset libx264-only + restore quality opt-in
-
-### Other
-
-- **release**: 0.9.11
-## [0.9.8] - 2026-05-27
-
-
-### Fixed
-
-- **upgrade**: break auto-apply restart loop (0.9.8)
-## [0.9.7] - 2026-05-26
-
-
-### Added
-
-- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
## [0.9.6] - 2026-05-26
-
### Added
-- **daemon**: auto-apply upgrades when server signals (0.9.6)
+- **auto-upgrade**: when the web flags the agent for upgrade
+ (`POST /api/internal/agent/upgrade` or the "Force update now" button),
+ the daemon now downloads and replaces the binary in-place, then exits so
+ the service supervisor (`systemd Restart=always` on Linux, the equivalent
+ on macOS/Windows) respawns on the new version. No `unarr update` step
+ required from the user. Still opt-in — only fires when the server sends
+ the upgrade signal.
+
+### Changed
+
+- The `OnUpgrade` daemon callback no longer just logs `run unarr self-update`;
+ it now triggers the actual upgrade in a background goroutine.
+
## [0.9.5] - 2026-05-26
-
### Added
-- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
-## [0.9.4] - 2026-05-26
-
-
-### Added
-
-- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
-## [0.9.3] - 2026-05-26
-
-
-### Added
-
-- **usenet**: warn at startup when par2 or extractor is missing
+- **funnel**: optional CloudFlare Quick Tunnel subprocess. `unarr funnel on`
+ spawns `cloudflared` as a child process and registers an anonymous
+ `https://.trycloudflare.com` hostname tunnelled to the daemon's
+ HLS server. The hostname is reported back to the web on every sync so the
+ in-browser player picks it up automatically — cross-network playback now
+ works on torrentclaw.com without Tailscale or port forwarding. Bytes
+ proxy through CloudFlare; TorrentClaw still doesn't relay content.
+- **funnel**: on by default for fresh installs (NAS/Docker get cross-network
+ HTTPS automatically); existing configs that pre-date the feature stay
+ off until the operator runs `unarr funnel on`.
+- **funnel**: auto-downloads cloudflared to the unarr data dir when not on
+ PATH (Linux amd64/arm64/armhf/386). ELF magic + size sanity check on the
+ download; `O_EXCL` partial-write so concurrent daemons don't clobber
+ each other.
+- **funnel**: subprocess supervisor keeps the tunnel up across cloudflared
+ crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min)
+ on persistent failures. The web's reported URL is cleared the moment
+ cloudflared exits so an outdated hostname doesn't keep handing out 502s.
+- **funnel**: `unarr funnel status` shows the live URL once registered.
+ See README §`[downloads.funnel]` for the throughput / latency caveats of
+ CF's free Quick Tunnels.
+- **docker**: the official `torrentclaw/unarr` image now bundles
+ `cloudflared` so the funnel works the moment the container starts — no
+ first-run download.
### Fixed
-- **engine**: truncate errorMessage before reporting status
-- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
-## [0.9.2] - 2026-05-22
+- **hls/libx264**: bump the H.264 level we hint to libx264 by one tier so
+ anamorphic (>16:9) sources stop emitting unplayable streams. 720p at
+ level 3.1 silently rejected 1728×720 cinemascope frames with
+ `frame MB size > level limit`; 720p now ships at level 4.0, 1080p at 4.1.
+ Decoder compatibility is unaffected — every device that handles 1080p
+ already handles ≥ 4.1.
+## [0.9.4] - 2026-05-26
+
+### Removed
+
+- **streaming**: retire the custom WebRTC DataChannel pipeline. The daemon no
+ longer ships pion/webrtc, the WSS signaling client, or the wire framing
+ package — every in-browser session now uses HLS over HTTP from the daemon
+ (Tailscale / LAN / UPnP). Browser P2P (WebTorrent) bytes never re-enabled.
+- **config**: `[downloads.webrtc]` block removed from the TOML schema; existing
+ config files with the section parse cleanly because go-toml ignores unknown
+ sections.
+- **seed_file**: `mode=seed_file` task handler + `engine.SeedFile` helper
+ dropped — the last in-browser caller was retired with the WebRTC player.
+- **wstracker-probe**: standalone probe binary removed.
+
+### Changed
+
+- **agent wire**: `SyncResponse.WebRTCSessions` (JSON: `webrtcSessions`) renamed
+ to `StreamSessions` (JSON: `streamSessions`). The Go type `agent.WebRTCSession`
+ is now `agent.StreamSession`. Wire-incompatible with web < 2026-05-26.
+- **torrent**: `buildMagnet` no longer accepts an `extraTrackers` variadic —
+ the default tracker list is the only set used.
+
+### Fixed
+
+- **hls**: clamp the ffmpeg `-b:v` to the bitrate cap derived from the EFFECTIVE
+ output height instead of the requested quality. Previously asking for "2160p"
+ on a 1080p source overshot the H.264 level we resolved from the effective
+ height (4.0, max 20 Mbps) and made libx264 abort with
+ `VBV bitrate > level limit`.
+
+## [0.9.2] - 2026-05-21
### Added
-- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
+- **vpn**: `unarr vpn` command (`status`, `enable`, `disable`) to manage the managed
+ WireGuard split-tunnel, with `vpn status --check` to verify provisioning.
+- **vpn**: report split-tunnel state (active, exit server) to the web on register
+ + every sync, so the dashboard shows which agent holds the single WireGuard slot.
+- **vpn**: send the agent id when fetching the VPN config so the web can arbitrate
+ the single WireGuard slot — the first agent claims it; the rest are told to run
+ OpenVPN on their own host (1 agent on WireGuard + up to 9 on OpenVPN).
+
## [0.9.1] - 2026-05-21
@@ -424,10 +113,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
@@ -437,10 +122,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
@@ -454,8 +135,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
@@ -829,34 +508,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
-[1.1.3-beta]: https://github.com/torrentclaw/unarr/compare/v1.1.2-beta...v1.1.3-beta
-[1.1.2-beta]: https://github.com/torrentclaw/unarr/compare/v1.1.1-beta...v1.1.2-beta
-[1.1.1-beta]: https://github.com/torrentclaw/unarr/compare/v1.1.0-beta...v1.1.1-beta
-[1.1.0-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.9-beta...v1.1.0-beta
-[1.0.9-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.8-beta...v1.0.9-beta
-[1.0.8-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.7-beta...v1.0.8-beta
-[1.0.7-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.6-beta...v1.0.7-beta
-[1.0.6-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.5-beta...v1.0.6-beta
-[1.0.5-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.4-beta...v1.0.5-beta
-[1.0.4-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.3-beta...v1.0.4-beta
-[1.0.3-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.2-beta...v1.0.3-beta
-[1.0.2-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.1-beta...v1.0.2-beta
-[1.0.1-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.0-beta...v1.0.1-beta
-[1.0.0-beta]: https://github.com/torrentclaw/unarr/compare/v0.9.19...v1.0.0-beta
-[0.9.19]: https://github.com/torrentclaw/unarr/compare/v0.9.18...v0.9.19
-[0.9.18]: https://github.com/torrentclaw/unarr/compare/v0.9.17...v0.9.18
-[0.9.17]: https://github.com/torrentclaw/unarr/compare/v0.9.15...v0.9.17
-[0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15
-[0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14
-[0.9.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13
-[0.9.11]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.11
-[0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8
-[0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7
-[0.9.6]: https://github.com/torrentclaw/unarr/compare/v0.9.5...v0.9.6
-[0.9.5]: https://github.com/torrentclaw/unarr/compare/v0.9.4...v0.9.5
-[0.9.4]: https://github.com/torrentclaw/unarr/compare/v0.9.3...v0.9.4
-[0.9.3]: https://github.com/torrentclaw/unarr/compare/v0.9.2...v0.9.3
-[0.9.2]: https://github.com/torrentclaw/unarr/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/torrentclaw/unarr/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/torrentclaw/unarr/compare/v0.8.1...v0.9.0
[0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1
diff --git a/DOCKERHUB.md b/DOCKERHUB.md
index 3df5b70..7a9bc0e 100644
--- a/DOCKERHUB.md
+++ b/DOCKERHUB.md
@@ -1,9 +1,8 @@
# unarr
-**The single binary that replaces your whole *arr stack.** Built-in torrent,
-debrid, and usenet engines. Stream, transcode, and organize your library from
-one terminal — or run it as a headless daemon with a web dashboard, WireGuard
-split-tunnel, and Cloudflare Funnel remote access.
+**The single binary that replaces your whole *arr stack.** Search 30+ torrent
+sources, inspect real quality before you download, grab subtitles, and manage
+your media library — all from one terminal tool or a headless daemon.
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
diff --git a/Dockerfile b/Dockerfile
index 2a70222..64ea4e2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,5 @@
# ---- Build stage ----
-# Pin the builder to the host's native arch and cross-compile (CGO is off, so
-# Go cross-compiles trivially). During multi-arch buildx this keeps `go build`
-# at native speed instead of compiling under QEMU emulation for the foreign arch.
-FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
+FROM golang:1.25-alpine AS builder
RUN apk add --no-cache git ca-certificates
@@ -16,90 +13,34 @@ RUN go mod download
COPY . .
ARG VERSION=dev
-ARG TARGETOS
-ARG TARGETARCH
-RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/
+RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/
# ---- Runtime stage ----
-# glibc base (not Alpine/musl). NVIDIA's userspace — nvidia-smi and the
-# libnvidia-encode / libcuda libs that `--gpus all` injects, plus the static
-# BtbN ffmpeg that links nvenc — are all glibc ELF. On musl they fail with
-# "no such file or directory" (missing glibc loader), so HW transcode is
-# impossible on Alpine. bookworm-slim is the smallest base that runs the full
-# NVIDIA stack while still falling back to software libx264 when no GPU is
-# passed in.
-FROM debian:bookworm-slim
+FROM alpine:3.22
-# par2 → repair corrupted Usenet segments (without it a single bad segment
-# silently corrupts the output).
-# 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads
-# RAR5, so unrar — unavailable as a free Debian package — isn't needed).
-# tzdata/ca-certificates → TLS + correct local time for schedules/logs.
-# libvulkan1 → the Vulkan loader (libvulkan.so.1). ffmpeg's libplacebo filter
-# (GPU HDR→SDR tonemap) loads Vulkan dynamically through it; without the
-# loader the filter can't reach a GPU even when the NVIDIA driver mounts
-# its ICD. ~150 KB. The agent only USES libplacebo after a functional
-# probe (FFmpegSupportsLibplacebo) succeeds AND a real HW encoder is
-# present, so this is inert on hosts without a working Vulkan GPU.
-#
-# NOTE: in this container libplacebo's Vulkan probe ALWAYS fails and the
-# agent falls back to the CPU zscale tonemap chain — by design, not a
-# bug. The nvidia Vulkan ICD is libGLX_nvidia.so.0, whose GL backend
-# (libnvidia-glcore) references glibc malloc hooks removed in glibc 2.34
-# (__malloc_hook/__free_hook/...) and the Xorg symbol ErrorF; on a
-# headless modern-glibc base (debian or ubuntu) those go unresolved so
-# vkCreateInstance returns VK_ERROR_INCOMPATIBLE_DRIVER. We deliberately
-# do NOT chase it (would need `graphics` cap + X11 libs + a 1.4 loader
-# AND a desktop-class glibc/Xorg — fragile, distro+driver coupled). The
-# loader stays so that on the RARE host where Vulkan does come up the
-# probe can use it. nvenc/nvdec (CUDA, not Vulkan) work regardless.
-# GPU HDR tonemap is a bare-metal-binary feature, not a container one.
-RUN apt-get update && \
- apt-get install -y --no-install-recommends \
- ca-certificates tzdata wget xz-utils par2 p7zip-full libvulkan1 && \
- rm -rf /var/lib/apt/lists/*
-
-# Arch for the bundled deps below is taken from `dpkg --print-architecture` (the
-# real arch of THIS runtime stage), NOT the TARGETARCH build-arg. A baked
-# `ARG TARGETARCH=amd64` default used to shadow buildx's per-leg value in this
-# stage, so even the published arm64 image bundled an amd64 cloudflared/ffmpeg
-# while the unarr binary was native arm64 → "exec format error" when the daemon
-# spawned cloudflared → funnel never came up → TV/Stremio connect failed
-# ("Failed to get add-on manifest"). dpkg reads the emulated base image's arch,
-# so it is correct under buildx cross-builds AND a plain `docker build`.
-
-# Static GPL ffmpeg + ffprobe with nvenc compiled in (BtbN builds). nvenc is
-# linked but the actual libnvidia-encode.so is dlopen'd at runtime from the
-# host driver that `--gpus all` exposes — so the same binary does HW transcode
-# when a GPU is present and falls back to libx264 when it isn't. Placed in
-# /usr/local/bin so ResolveFFmpeg picks them up off PATH ahead of any distro
-# ffmpeg. arm64 has no nvenc but the build still serves software transcode.
-RUN ARCH="$(dpkg --print-architecture)" && \
- case "$ARCH" in \
- amd64) FF_ARCH=linux64 ;; \
- arm64) FF_ARCH=linuxarm64 ;; \
- *) echo "unsupported arch=$ARCH" >&2; exit 1 ;; \
- esac && \
- wget -4 --tries=3 --timeout=30 -qO /tmp/ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${FF_ARCH}-gpl.tar.xz" && \
- mkdir -p /tmp/ff && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ff --strip-components=1 && \
- cp /tmp/ff/bin/ffmpeg /tmp/ff/bin/ffprobe /usr/local/bin/ && \
- chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
- rm -rf /tmp/ffmpeg.tar.xz /tmp/ff
+# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle /
+# BtbN static glibc builds — those need a glibc shim on Alpine and the
+# vector-math symbols the GPL builds reference are not satisfiable by
+# gcompat. Alpine ships ffmpeg ~7.x which is fine for the HLS transcoding
+# pipeline (libx264 + libfdk-aac alternatives included).
+RUN apk upgrade --no-cache && \
+ apk add --no-cache ca-certificates tzdata ffmpeg wget
# Bundle cloudflared so `unarr funnel on` (default: on, see config defaults)
# Just Works on a headless container with no first-run network round-trip.
-RUN ARCH="$(dpkg --print-architecture)" && \
- case "$ARCH" in \
- amd64) CF_ARCH=amd64 ;; \
- arm64) CF_ARCH=arm64 ;; \
- armhf) CF_ARCH=armhf ;; \
- *) echo "unsupported arch=$ARCH" >&2; exit 1 ;; \
+# TARGETARCH is set automatically by Docker buildx during cross-builds.
+ARG TARGETARCH=amd64
+RUN case "$TARGETARCH" in \
+ amd64) CF_ARCH=amd64 ;; \
+ arm64) CF_ARCH=arm64 ;; \
+ arm) CF_ARCH=armhf ;; \
+ *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
esac && \
- wget -4 --tries=3 --timeout=30 -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \
+ wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \
chmod +x /usr/local/bin/cloudflared
# Non-root user (UID 1000 matches typical host user for volume permissions)
-RUN groupadd -g 1000 unarr && useradd -u 1000 -g 1000 -m -d /home/unarr unarr
+RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr
# Default directories
RUN mkdir -p /config /downloads /data && \
@@ -114,23 +55,6 @@ ENV UNARR_CONFIG_DIR=/config
ENV UNARR_DOWNLOAD_DIR=/downloads
ENV XDG_DATA_HOME=/data
-# Mark this as a container install so the agent reports isDocker=true to the web
-# (which then shows a `docker pull` command instead of the in-app update button —
-# the binary self-update refuses to run in Docker). Covers podman/containerd too,
-# which don't create /.dockerenv. See internal/agent/RunningInDocker.
-ENV UNARR_DOCKER=1
-
-# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" +
-# "compute" capabilities; nvenc needs "video", and "graphics" makes the runtime
-# mount the NVIDIA Vulkan ICD (nvidia_icd.json — the load-bearing piece — plus
-# GLX/EGL libs) so ffmpeg's libplacebo filter (GPU HDR tonemap, paired with
-# libvulkan1 above) can create a Vulkan device. "compute" alone does NOT mount
-# the ICD. Baking these here means a plain `docker run --gpus all` (or the compose
-# device reservation) lights up HW transcode + GPU tonemap with zero extra flags.
-# Harmless when no GPU is attached.
-ENV NVIDIA_VISIBLE_DEVICES=all
-ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility,graphics
-
VOLUME ["/config", "/downloads", "/data"]
ENTRYPOINT ["unarr"]
diff --git a/Docs/plans/unarr-agent-roadmap.md b/Docs/plans/unarr-agent-roadmap.md
deleted file mode 100644
index 3b2b693..0000000
--- a/Docs/plans/unarr-agent-roadmap.md
+++ /dev/null
@@ -1,661 +0,0 @@
-# unarr CLI agent — roadmap del diferenciador
-
-> Estado de partida: **v0.9.19 beta** (~26k LOC fuente / ~18k test).
-> Objetivo estratégico: el agente CLI es el **soporte real y diferenciador** de
-> unarr — un *servidor de streaming personal* que la web sola no puede ser.
-> Compite en **profundidad**, no en anchura (no apps nativas por dispositivo:
-> el agente sirve a un único web-player responsive vía navegador).
-
-## La visión en 6 puntos
-
-1. **Hospeda localmente** toda la biblioteca.
-2. **Debrid** para reproducir cualquier cosa cache-fast.
-3. **Play-anything sin callejones** (local | debrid | descarga-y-reproduce, con
- fallback mid-stream).
-4. **Transcodifica según el dispositivo** (direct-play cuando ya es compatible).
-5. **Sirve a un web-player universal** en cualquier dispositivo vía navegador.
-6. **Acceso remoto seguro** al agente.
-
-## Mapa de partida (qué TIENE el agente hoy)
-
-Sólido salvo nota:
-
-- **Descarga torrent** (anacrolix): mmap, DHT warm-start, 30 trackers, pause/cancel,
- selección vídeo+subs `[engine/torrent.go]`. **Stream-while-download** con reader
- responsive + `PrioritizeTail` `[engine/stream.go]`.
-- **Usenet** completo: NNTP pool, yEnc, ensamblado `WriteAt`, resume por segmento,
- par2 repair, unrar/7z `[usenet/*]`.
-- **Debrid downloader**: GET con Range/resume `[engine/debrid.go]` — pero solo
- DESCARGA (no streaming). Resolución server-side.
-- **HLS transcode** fMP4 + seek real + supervisor `[engine/hls.go]`, **caché HLS LRU**
- `[engine/hls_cache.go]`, **HW accel** NVENC/QSV/VAAPI/VideoToolbox `[engine/hwaccel.go]`.
-- **Servidor HTTP** persistente: range/seek, rate-limit 2×bitrate, CORS `[engine/stream_server.go]`.
-- **Library scan + ffprobe** (codec/HDR/tracks), parse título/temporada `[library/, mediainfo/]`.
-- **Red**: CloudFlare Quick Tunnel `[funnel/]`, WireGuard userspace split-tunnel `[vpn/]`,
- NAT-PMP + UPnP `[engine/upnp.go]`. Web hace de broker de URLs (LAN/Tailscale/Public/Funnel).
-- **Agente**: daemon cobra, sync HTTP long-poll + `/wake`, auto-upgrade opt-in,
- config.toml exhaustivo.
-
-## Huecos (de más crítico a más bajo)
-
-### Hueco #1 — Auth de stream ✅ CERRADO (2026-05-31) / ver estado abajo
-`/stream` y `/hls` se sirven **sin autenticación** (solo CORS+rate-limit). Con
-funnel/UPnP el stream queda público en internet. Plan previo
-`Docs/plans/security-stream-token.md` (deferido, sin código).
-
-### Hueco #2 — Debrid en el path de streaming ✅ CERRADO (2a+2b+2c, 2026-05-31)
-Hoy debrid es **solo descarga**, resuelto server-side; el streaming es 100%
-torrent. La promesa "play instantáneo cache-fast" no ocurre. Falta: source debrid
-en el path de streaming + cache-availability + **fallback torrent↔debrid mid-stream**.
-Diseño por fases (2a direct-play / 2b HLS-desde-URL / 2c fallback) en el estado abajo.
-
-### Hueco #3 — Device-profile + direct-play + ABR ✅ CERRADO (2026-05-31) / ver estado abajo
-El path HLS re-encodaba todo (incluso mp4 h264/aac ya compatible). `DecideAction`
-muerto. Sin negociación por capacidades. Sin adaptación de calidad.
-Diseño por fases (3a direct-play / 3b remux fMP4 / 3c capability-negotiation / 3d ABR)
-en el estado abajo. **3a + 3b + 3c CERRADAS** (smoke e2e, incl. HEVC en iPhone Safari
-real). **3d resuelto como 3d-lite (auto-downshift)** — ABR multi-rendition real
-descartada (N× CPU inviable single-viewer; no aplica a paths copy). Hueco COMPLETO.
-
-### Hueco #4 — Pre-transcode (transcode-on-download) 🔵 DISEÑADO (ver estado abajo)
-Al completar una descarga/import, transcodificar/remuxar en background para que el
-PRIMER play sea instantáneo (direct o cache-HIT), sin transcode en vivo.
-Optimización, nunca bloqueante: si no terminó a tiempo → fallback a transcode en
-vivo (HLS actual). Reaprovecha `hls_cache.go` (cache-HIT ya sirve instantáneo) +
-el pipeline de `prewarm` (ya hace encode de la siguiente ep) — generaliza prewarm a
-"todo download, configurable" y puebla también el artefacto direct-play. Configurable
-desde la web. Diseño + set de opciones en el estado abajo.
-
-### Huecos medios ⬜
-- ~~Sin gestión de espacio en disco (`Statfs`)~~ ✅ **Pre-flight de espacio (2026-05-31)** — `CheckDiskSpace` antes de cada descarga (torrent/usenet/debrid) con reserva configurable `downloads.min_free_disk_mb` (default 2048); manager NO hace fallback en disco lleno; aviso web 507 `INSUFFICIENT_DISK` al despachar (torrentclaw). Monitoreo mid-download diferido. Ver estado abajo.
-- ~~Resume de torrent NO persiste reinicio del daemon~~ ✅ **Auto-resume tras reinicio (2026-05-31)** — `agent.ActiveTaskStore` persiste los `agent.Task` de descargas en vuelo (`active-tasks.json`); el daemon los re-somete al arrancar → los downloaders reanudan los bytes (torrent vía completion DB de anacrolix, debrid vía Range, usenet vía tracker). Dedup en `manager.Submit` (restore + re-despacho web no duplican). `shuttingDown` preserva el entry en apagado limpio (solo terminal genuino lo borra). Ver estado abajo.
-- ~~Sin seeding/ratio lifecycle (flags existen, nadie los aplica)~~ ✅ **Seeding/ratio lifecycle (2026-06-01)** — `seed_enabled`/`seed_ratio`/`seed_time` en `[downloads]` (opt-in, off por defecto) cableados al daemon; al completar una descarga con seeding activo el torrent sigue subiendo en background y un monitor lo dropea al alcanzar ratio (subido/tamaño) O tiempo (lo primero que toque); sin target = siembra hasta apagado. `cleanup()` ahora siempre dropea (arregla fuga en rutas de error con seeding on). Verificado con swarm loopback real. Ver estado abajo.
-- ~~Reproducir-mientras-baja: readahead estático 5MB~~ ✅ **Readahead dinámico (2026-05-31)** — `dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 8–96 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo.
-- ~~HDR→SDR sin tonemap~~ ✅ **Tonemap HDR→SDR (2026-05-31)** — cadena `zscale+tonemap=hable` en el transcode HLS cuando la fuente es HDR y el ffmpeg trae zscale (detectado en runtime, `FFmpegSupportsZscale`; sin zscale → comportamiento actual, no rompe). Verificado en 4K HDR10 real: de lavado/desaturado a colores vívidos. Ver estado abajo.
-- ~~Sin thumbnails~~ ✅ **Fotogramas bajo demanda (2026-05-31)** — `GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Ver estado abajo.
-- ~~Sin trickplay (preview en la barra)~~ ✅ **Trickplay bajo demanda (2026-06-01)** — pista WebVTT `thumbnails` con 1 cue/10s; cada cue es una URL `/thumbnail?pos=…#xywh=0,0,W,H` (frame completo), así media-chrome solo descarga el frame que se sobrevuela. Toggle on/off por navegador (`localStorage`, default ON) + doc (web `docs/architecture/trickplay.md`). Alcance "on-demand" decidido con el usuario. Sin pregenerar sprite/BIF (sigue siendo opción futura con cacheo en disco). Ver estado abajo.
-- ~~Subtítulos bitmap (PGS/DVB) sin burn-in~~ ✅ **Burn-in PGS/DVB bajo demanda (2026-06-01)** — el usuario elige una pista bitmap en el reproductor → la sesión fuerza HLS y el agente re-codifica con `[0:v:0][base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]` (overlay tras el tonemap = brillo SDR correcto; scale2ref = encaje a cualquier resolución del PGS). En la cache key. Selector web alimentado de file-details (funciona también en direct-play). Caveat: PGS + seek pierde el subtítulo. Verificado en Sonic BDremux (ES quemado). Ver estado abajo.
-- ~~Audio siempre downmix estéreo AAC (sin passthrough 5.1)~~ ✅ **Verificado/descartado (2026-06-01)** — el 5.1 in-browser NO es viable (el navegador decodifica+mezcla al dispositivo, no hace bitstream-passthrough; AC3/EAC3/DTS ni se decodifican en Chrome/FF). El downmix solo ocurre en el path HLS. El handoff a player nativo (VLC/mpv/IINA/MPC/Infuse + .m3u/.strm) ya usa `/stream` **crudo** (`http.ServeContent` + `NewFileReader`, sin transcode) → el 5.1/Atmos/DTS original llega intacto al reproductor nativo. Sin trabajo necesario.
-- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia. **(diferido al final por decisión del operador)**
-- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS). ⏸️ **Cimiento construido + DIFERIDO (2026-06-01)** — listener HTTPS por-agente con cert hot-reload (commit `27bee8c`, inerte sin cert). Decisión: MVP CF-only (single-SAN por agente, DNS-01 vía CF API, sin DNS propio); fase broker+DNS diferida. Doc: web `docs/plans/agent-tls-direct.md`.
-- Funnel = SPOF CloudFlare (rota ~6h), sin relay propio.
-- ~~"Tailscale Funnel" mal nombrado~~ ✅ **Ya correcto (2026-06-01)** — no existe el literal en ningún sitio del código; el comando, el help y los docs nombran consistentemente "CloudFlare Quick Tunnel". La nota era stale; nada que renombrar.
-- ~~Dos clientes HTTP divergentes (go-client vs agent client)~~ ✅ (resuelto — ver sección Cerrada).
-- ~~Long-poll en vez de WS/SSE~~ ✅ **Realtime: SSE downlink + uplink event-driven + push al navegador (2026-06-01, CLI 0.14.0)** — las 3 patas de la comunicación agente↔web↔navegador:
- 1. **Downlink (server→agente):** `GET /api/internal/agent/events` (SSE) empuja `event: command` (controles tipados desde DB, no-consuming) + `event: sync` (nudge), heartbeat 15s, colgado del Redis pub/sub `agent:wake` (multi-replica). El CLI lo consume SSE-first con **fallback a long-poll liveness-probed** (SSE es buffering-intolerante; long-poll es buffering-tolerante → red de seguridad para proxies/ISP que bufferean). Config `[daemon] downlink=auto|sse|poll`. Cliente SSE resucitado del `signal_client.go` histórico.
- 2. **Uplink (agente→server):** cada transición de estado del `Task` dispara `onChange→TriggerSync` (coalescido), en vez de esperar al tick adaptativo 3s/10s. Cubre descargas y streams.
- 3. **Browser-leg (server→navegador):** `/agent/sync` publica en un signal-bus Redis genérico (`createSignalBus`); `progress-stream` se suscribe y empuja snapshot al instante (backstop 10s, antes busy-poll 3s) + dedupe de frames idénticos en el cliente. `markWatching` despierta al agente en el flanco para reporte 3s inmediato al abrir la página.
-
- Verificado e2e (control instantáneo + fallback + push). De paso: arreglado el allow-list de marca unarr que 404eaba `/api/internal/downloads|library|profile|…`. Commits web `11b70fae`/`1e77b948`/`bdb0ab92`/`cf3e4423`, cli `1052529`/`864b6ea`.
-
-### Deuda puntual
-VAAPI workarounds por host · sesión única (1 viewer).
-
-**Cerrada (2026-06-01):**
-- ~~`makeReadable` parchea mmap 0000 (frágil NFS)~~ ✅ tras el chmod ahora **verifica** que el fichero abre; si no (NFS root_squash / mapeo uid SMB) emite un WARNING claro y accionable + cuenta de fallos en el walk, en vez de dejar un "permission denied" críptico aguas abajo.
-- ~~par2/unrar degradan en silencio si falta binario~~ ✅ `Par2Verify`/`Par2Repair` devuelven `ErrPar2NotInstalled` (antes `nil`=verificado); el pipeline lo surfacea (`Result.VerifyNote` + WARNING) → la descarga se entrega marcada UNVERIFIED, no como verificada. (El lado extract ya fallaba claro.)
-- ~~cloudflared sin verificación de firma~~ ✅ el auto-download ahora fija la versión (`pinnedCloudflaredVersion`) y **verifica SHA-256** contra hashes horneados (no `latest`); un release upstream malicioso/roto ya no se trae en silencio.
-- ~~WireGuard endpoint sin pin~~ ✅ **descartado**: el reseller de VPN (VPNResellers) usa configuración WireGuard directa sin pin de endpoint; no aplica.
-- ~~Dos clientes HTTP divergentes (go-client vs agent)~~ ✅ el go-client (API público: search/popular/etc.) ahora recibe **mirror-failover** vía un `MirrorRoundTripper` que reusa el mismo `MirrorPool` + política `IsTransient` del agent client (inyectado con `tc.WithHTTPClient`) → ambos sobreviven una caída del dominio primario igual; antes el público se quedaba clavado en el primario.
-
-## Mejoras detectadas durante el trabajo (backlog)
-
-> Se rellena a medida que se trabaja cada hueco. Cada entrada: qué, por qué, prioridad.
-
-- **Clock-skew en verificación de token** (baja): `verifyStreamToken` no tolera skew; con TTL 6h y NTP es irrelevante, pero el HLS lo mintea el web y lo verifica el agente (relojes distintos). Considerar ~60s de gracia si aparecen 404 espurios.
-- **Secreto de stream en claro en DB** (baja): `agent_registration.stream_secret` es una clave HMAC viva (por arranque) en la DB central; quien lea la DB puede mintear tokens HLS de cualquier agente. Inherente al diseño (el web debe mintear HLS). Mitigado por regeneración por arranque. Excluir esta columna de cualquier JSON admin/usuario.
-- **Refrescar/limpiar streamUrl al re-registrar** (baja): tras reinicio del daemon el secreto cambia; URLs `?t=` ya guardadas en `download_task.streamUrl` quedan stale hasta re-stream. Es auto-curativo, pero el web podría limpiar streamUrl en el re-register del agente.
-- **gofmt preexistente** en `internal/agent/types.go` (StreamSession) y `hls.go`/`torrent.go`/`stream_source.go` (no introducido por este trabajo) — chore aparte.
-- **Data race preexistente manager↔reporter (baja)**: bajo `-race`, `Task.ToStatusUpdate()` (leído por `ProgressReporter.flushBatch`) corre sin lock contra la escritura de campos del task en `processTask` (`manager.go:371`). No introducido por el resume; expuesto al correr la suite con `-race` (la suite normal corre sin `-race`). Fix: proteger los campos de estado/progreso del `Task` con su `mu` en ToStatusUpdate + processTask. Chore aparte. Múltiples `task.ID[:8]` en `progress.go`/`torrent.go` paniquean con ids <8 chars (irreal: el web manda UUIDs) — limpiar a `ShortID` de paso.
-- **Funnel = SPOF CloudFlare** (ya en huecos medios): el funnel sigue siendo trycloudflare; relay propio pendiente.
-- ~~**Rutas localizadas unarr 404 (media)**~~ ✅ **ARREGLADO (2026-05-31)**: bajo `NEXT_PUBLIC_BRAND=unarr` el allowlist `UNARR_PAGE_PREFIXES` (paths EN) no reconocía los localizados de next-intl (`/es/biblioteca`, `/es/descargas`, `/es/perfil`) → 404. Fix (web): `enFirstSegmentByLocalized` (mapa localizado→EN derivado de `routing.pathnames`) + `toCanonicalPath()` en `branding/routes.ts` traduce el 1er segmento antes del match. Assertion anti-colisión en el build del mapa (fail-fast si una ruta futura reusa un segmento → no puede colar una ruta denegada). Verificado: 175 entradas, cero crossover; denegadas siguen denegadas.
-- ~~**Thumbnails — sprites/trickplay (media)**~~ ✅ **Trickplay CERRADO bajo demanda (2026-06-01)**: la preview de barra usa cues `/thumbnail` en vivo (un frame por cue al sobrevolar), no un sprite pregenerado. El sprite/BIF de toda la timeline con cacheo en disco del agente sigue siendo una optimización futura (no necesaria para la UX actual). Ver estado abajo.
-- **nvenc "Invalid Level" en fuentes anamórficas (alta — destapado en el smoke de trickplay)** ✅ **ARREGLADO (2026-06-01)**: el nivel H.264 del transcode HLS se derivaba solo de la altura → una fuente 2.39:1 escalada a 1080 (~2586×1080 = 11016 MBs) revienta el `MaxFS` de L4.1 (8192); ffmpeg fallaba (`InitializeEncoder failed: invalid param (8): Invalid Level` en nvenc, `frame MB size > level limit` en libx264) y la sesión no producía ningún segmento. Casi todos los rips 4K son anamórficos → reproducción HLS rota en silencio. Fix (`hwaccel.go`): `H264LevelForFrame(width,height)` deriva el nivel del recuento de macrobloques real (máx. entre el nivel por-altura y el por-MB); `hls.go` calcula el ancho de salida y lo usa. Ver estado abajo.
-
-### Hueco medio — Readahead dinámico (ver-mientras-baja) ✅ CERRADO (2026-05-31)
-El lector de torrent usaba un readahead **estático de 5 MiB** (~1.9s de un stream 4K de 20 Mbps) → al reproducir un torrent a medio bajar, la reproducción adelantaba a la descarga y se atascaba.
-- `dynamicReadahead(bitrateBps)` (`readahead.go`): ~30s de vídeo, clamp [8, 96] MiB; default 24 MiB cuando el bitrate es desconocido (ya ~5× el viejo 5 MiB). anacrolix (`SetResponsive`+`SetReadahead`) ya prioriza las piezas de esa ventana por delante del read position y re-prioriza en seek — el feedback playhead→prioridad estaba; solo faltaba dimensionar la ventana.
-- `torrentFileProvider` lleva `bitrateBps atomic.Int64`, sondeado **async** (`probeMediaInfo` en goroutine vía DataDir+DisplayPath) — sin coste de TTFF; hasta resolverse usa el default, y los readers posteriores (cada range/seek crea uno) cogen el valor preciso. StreamEngine (CLI) → default 24 MiB.
-- **Smoke**: ffprobe en 4K real (20.7 Mbps) → readahead **73 MiB** (~28s) vs 5 MiB. Tests del func puro + -race limpio en el probe async. /critico: código sólido, fix aplicado (probe síncrono→async para eliminar 3s de TTFF si falta la cabecera).
-
-### Hueco medio — Trickplay (preview en la barra) ✅ CERRADO (2026-06-01)
-Preview de fotograma al pasar el ratón por la barra de búsqueda, **bajo demanda** (sin pregenerar sprite). Alcance decidido con el usuario: on-demand + UX no invasiva + activable/desactivable + documentado.
-- **Web** (rama `feat/unarr-brand`): `buildTrickplayVtt()` (`src/lib/stream/trickplay.ts`) emite una pista WebVTT `thumbnails` con 1 cue/10s; cada cue apunta a `GET /thumbnail?pos=&w=320#xywh=0,0,W,H` (frame completo, alto par derivado del aspecto). media-chrome solo descarga el frame sobrevolado y lo cachea. Wiring en `HlsStreamPlayer` (fetch a `file-details` → blob VTT → ``), botón on/off + var CSS de fondo en `MediaChromePlayer`, toggle por navegador en `localStorage` (`useTrickplay`, default ON). Doc: `docs/architecture/trickplay.md`. Tests: `trickplay.test.ts` (6, formato cue + alto par + token vacío + inputs insuficientes).
-- **Smoke real** (iPhone-equiv en Chrome, F1 4K DV+HDR10): vídeo reproduce → hover en la barra renderiza un frame real en la posición (1:17:36) ≠ el frame en curso; etiqueta de tiempo inmediata; toggle off → `` desaparece (sin preview) y persiste `localStorage="0"`; toggle on → vuelven los 932 cues. CORS del ` ` OK (allowlist del agente).
-- **No invasivo**: nada carga hasta el hover; 1er frame ~0.8–2.1s en 4K-desde-NAS, re-hover instantáneo (caché navegador); la etiqueta de tiempo aparece ya aunque el frame se esté generando.
-
-### Hueco medio — Burn-in de subtítulos bitmap (PGS/DVB) ✅ CERRADO (2026-06-01)
-Los subs de imagen (PGS/DVB/VOBSUB) no se pueden servir como WebVTT; se incrustan en el vídeo durante el transcode. Alcance (decidido con el usuario): bajo demanda + nudge cuando el fichero SOLO tiene bitmap (sin auto-activar).
-- **Agente** (rama `unarr-burnin` ex `feat/unarr-agent`): `HLSSessionConfig.BurnSubtitleIndex *int` (nil=sin burn; puntero para que el 0 no se confunda con "quema pista 0"); en la cache key (`KeyFor`/`KeyForID`). `buildHLSFFmpegArgsAt`: si el índice apunta a una pista bitmap válida, `-map [vout]` + `-filter_complex [0:v:0][base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]`. Overlay TRAS el tonemap (subs SDR no se aplastan); scale2ref encaja el lienzo PGS al frame. Índice inválido/texto/fuera de rango → fallback a encode limpio (log). `IsTextSubtitle` ahora incluye `"text"` (paridad con el clasificador web). Tests `TestBuildHLSFFmpegArgsBurnSubtitle` (filter_complex/overlay/[vout] vs -vf según bitmap/texto/rango) + cache-key.
-- **Web** (rama `unarr-burnin` ex `feat/unarr-brand`): columna `streaming_session.burn_subtitle_index` (migración 0139, NOT NULL default -1) en identidad de sesión + dedup; `session/route` fuerza `playMethod=hls` cuando hay burn; `agent.ts` lo pasa al daemon. Selector en `MediaChromePlayer` alimentado de **file-details** (`subtitleTracks`, mediainfo estática) → aparece también en direct-play; posición del array = `-map 0:s:N`. `isBitmapSubtitleCodec` (`src/lib/stream/subtitles.ts`) espeja `IsTextSubtitle`. Notice: "incrustando" al quemar / nudge si solo-bitmap. Doc: `docs/architecture/subtitle-burn-in.md`.
-- **Smoke real** (Sonic 2020 BDremux 1080p, 7 PGS + 1 subrip): selector lista los 7 PGS (EN/ES/NL · imagen), excluye el subrip; elegir ES (`0:s:2`) fuerza HLS, el agente transcodifica con overlay sin error y el frame muestra **"Sé lo que estáis pensando."** quemado (posición + brillo correctos). /critico 2 revisores: arreglado `"text"` (paridad), reset de burn al cambiar de ítem, `bitmapSubtitles` a flatMap.
-- **Caveat**: PGS + seek pierde el subtítulo (el `-ss` antes de `-i` tira el estado del decoder PGS). Reproducción lineal desde el inicio = OK. Mitigación futura: decodificar PGS desde el epoch cercano.
-- **Aislamiento**: este trabajo se hizo en worktrees dedicados (`/tmp/tc-unarr-{web,cli}`, rama `unarr-burnin`) tras una colisión de ramas en los checkouts primarios compartidos. Merge a `feat/unarr-{brand,agent}` pendiente de decisión del operador.
-
-### Bug agente — nvenc "Invalid Level" en fuentes anamórficas ✅ ARREGLADO (2026-06-01)
-Destapado durante el smoke de trickplay: el HLS de un 4K anamórfico (3840×1604, 2.39:1) no producía **ningún** segmento.
-- **Causa**: el nivel H.264 se derivaba solo de la altura de salida (`H264LevelForHeight`). Escalado a 1080 de alto, un 2.39:1 queda ~2586×1080 = 11016 macrobloques, que supera el `MaxFS` del nivel 4.1 (8192). ffmpeg fallaba al abrir el encoder (`InitializeEncoder failed: invalid param (8): Invalid Level` en h264_nvenc; el equivalente `frame MB size > level limit` en libx264) → 0 paquetes → la sesión se quedaba en "preparando sesión" hasta el timeout de mark-ready. Casi todo rip 4K es 2.39:1, así que la reproducción HLS estaba rota para la mayoría de pelis 4K (en silencio).
-- **Fix** (`hwaccel.go` + `hls.go`): `H264LevelForFrame(width, height)` deriva el nivel del recuento de macrobloques real (`levelForMacroblocks`, tabla MaxFS de la spec) y devuelve el máximo entre ese y el nivel por-altura (que conserva el margen de fps/MBPS). `hls.go` calcula el ancho de salida (`probe.Width * outputHeight / probe.Height`, par) y llama a `H264LevelForFrame`. 16:9 no cambia (mismo resultado que antes); anamórfico sube a 5.0 cuando hace falta. `transcoder.go` no se toca (su `SourceHeight` nunca se rellena → ya cae al default seguro 5.1).
-- **Reproducido + verificado**: con `/usr/bin/ffmpeg` 6.1.1 + nvenc, `testsrc=2586x1080 @ -level:v 4.1` reproduce el error exacto; `@ 5.0` codifica OK. Tras el fix, sesión HLS del F1 4K arranca sin "Invalid Level"/auto-restart/timeout y el `` carga (`readyState 4`, `duration 9313s`). Tests `H264LevelForFrame` (16:9 sin regresión + anamórfico → 5.0).
-
-### Hueco medio — Seeding/ratio lifecycle ✅ CERRADO (2026-06-01)
-Los flags `SeedRatio`/`SeedTime` (`TorrentConfig`) estaban DECLARADOS pero nadie los consumía, y `SeedEnabled` estaba hardcodeado a `false` en ambos constructores → el daemon nunca sembraba y, si se forzaba, sembraba para siempre.
-- **Config** (`config.go`): `[downloads]` += `seed_enabled` (bool), `seed_ratio` (float), `seed_time` (string duración tipo `"24h"`). Opt-in, off por defecto (zero-values = apagado, sin entradas en `applyDefaults`). Tests `TestLoadSeeding{DefaultsOff,Explicit}`.
-- **Wiring** (`daemon.go`): parsea `seed_time` (`time.ParseDuration`) y cablea los 3 campos a `TorrentConfig`; log de arranque que distingue ratio / tiempo / ambos / indefinido. El `unarr download` one-shot (foreground) sigue `SeedEnabled:false` a propósito (leech + exit; comentado).
-- **Ciclo** (`torrent.go`): `seedTargetReached(ratio, time, uploaded, size, elapsed)` puro (ratio = subido/tamaño-seleccionado, estable entre resumes; el primero de ratio>0 o tiempo>0 que se cumple gana; ambos 0 = nunca para). `seedAndDrop` corre detached en un `seedCtx` propio del downloader (cancelado en `Shutdown`) — NO el ctx de la task, que se cancela en cuanto `Download` retorna y el manager libera el slot. Tick configurable (`seedCheckInterval`, default 30s; tests lo bajan). Sale sin dropear si el handle ya se quitó de `d.active` (cancel/pause del usuario) → ni lee stats de un torrent cerrado ni dropea dos veces.
-- **Bug latente arreglado de paso**: `cleanup()` tenía `if !SeedEnabled { Drop }` — en rutas de ERROR (metadata timeout, disco, poll) con seeding activo borraba de `d.active` pero NO dropeaba → fuga. Ahora `cleanup()` siempre dropea (solo lo llama el error-path y el éxito-sin-seeding); el éxito-con-seeding hace el handoff a `seedAndDrop`.
-- **Smoke real** (`seed_lifecycle_smoke_test.go`, tag `smoke`): swarm loopback de dos clientes anacrolix (un seeder sirviendo 4 MiB + nuestro `TorrentDownloader` leecheándolo vía `AddClientPeer`). Tras completar (4194304 bytes reales transferidos), `seedAndDrop` con `SeedTime=1s` dispara el target de tiempo (`seed time 1s reached, uploaded 0 B — dropping`) y quita el torrent de `d.active`. Verifica el path real Stats/Drop/ticker, no mocks. Tests puros `TestSeedTargetReached` (9 casos: ratio/tiempo/ninguno/ambos/guarda-tamaño-0) + `TestTorrentDownloader_SeedRatioTime`.
-
-### Hueco medio — HDR→SDR tonemap en transcode ✅ CERRADO (2026-05-31)
-HDR (HDR10/HLG/DV) transcodificado a SDR salía lavado/desaturado (sin tonemap). Ahora `buildHLSFFmpegArgsAt` inserta `zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv` tras el scale y antes de `format=`, cuando `probe.HDR != "" && Transcode.TonemapHDR`.
-- **Gate por capacidad**: `FFmpegSupportsZscale(ffmpegPath)` (cacheado, `ffmpeg -filters`) → solo activa si el build trae zscale/zimg. Sin zscale → no se inserta (la fuente sigue reproduciéndose, desaturada — no rompe). `transcoder.go:270` ya advertía que builds sin zimg no pueden tonemapear; el static ffbinaries puede faltarle, pero `/usr/bin/ffmpeg` (distro) y el docker sí lo traen.
-- **Filtro CPU válido para todos los encoders**: el decode hwaccel deja los frames en memoria de sistema (no se setea `-hwaccel_output_format`), así que el zscale CPU corre antes del `format=`/`hwupload` (VAAPI) igual que el scale existente.
-- **Smoke real**: extraído un frame de un 4K HDR10 (Frankenstein DV+HDR10) con y sin la cadena → ambas válidas (sin error), la tonemapeada con rojo vívido + negros profundos vs la lavada. /critico 1 revisor: cadena correcta, sin bugs bloqueantes; fix aplicado (soltar mutex antes del exec en la detección), HLG/DV-only documentados como aproximación (mejor que el baseline).
-
-### Hueco medio — Auto-resume de descargas tras reinicio ✅ CERRADO (2026-05-31)
-Antes: tras reiniciar el daemon, una descarga en vuelo quedaba abandonada (cola in-memory perdida, el web no re-despacha una tarea "downloading" atascada) hasta reintento manual. Los BYTES ya persistían (mmap + completion DB BoltDB de anacrolix, keyed por info_hash; debrid Range; usenet tracker) — faltaba que el daemon se re-sometiera solo.
-- **`agent.ActiveTaskStore`** (`active-tasks.json`, atómico tmp+rename): persiste el payload `agent.Task` re-submittable de descargas en vuelo. Add al arrancar la descarga, Remove en terminal genuino.
-- **Manager**: interfaz `taskPersister` (inyectable/testeable) + `SetTaskStore`. `Submit` ahora DEDUPLICA (mismo id del restore + re-despacho web no lanzan 2 goroutines) y persiste descargas (no stream/seed/upgrade-ReplacePath). `recordFinished` borra del store SALVO `shuttingDown` (atomic) → un apagado limpio preserva el entry; terminal genuino (completado/fallo/cancel-usuario) lo borra. ForceStart se limpia en el re-submit (respeta MaxConcurrent).
-- **Daemon**: construye el store, `SetTaskStore`, y al arrancar re-somete `Load()` antes del sync loop.
-- **/critico**: 1 revisor → **bug CRÍTICO (conf 98)**: el daemon hacía `cancel()` (ctx padre) ANTES de `manager.Shutdown()` → contextos de tarea cancelados antes de marcar `shuttingDown` → recordFinished con shuttingDown=false → borraba el entry → NO resume (guard era código muerto). FIX: `Manager.Shutdown` cancela los contextos él mismo ANTES de `wg.Wait` (con shuttingDown ya puesto) + el daemon llama `Shutdown` antes de `cancel()`. + ForceStart-strip + excluir upgrade. Tests: store round-trip, dedup, persist/remove-terminal, keep-on-shutdown, stream-no-persiste.
-- **Smoke**: cubierto por unit tests (incl. shutdown-keeps). El e2e real (descarga → kill daemon → restart → resume) no se ejecutó para no reiniciar el agente dev en uso por el usuario.
-
-### Hueco medio — Gestión de espacio en disco (pre-flight) ✅ CERRADO (2026-05-31)
-Una descarga ya no llena el disco a 0 a mitad (corrompía el fichero parcial).
-- **CLI**: `internal/engine/diskspace.go` — `CheckDiskSpace(dir, need, reserve)` usa `agent.DiskInfo` (Statfs/GetDiskFreeSpaceEx, ya abstraído) y devuelve `*InsufficientDiskError` si `free-need < reserve`; best-effort (need≤0 o stat falla → nil, ENOSPC sigue de backstop). Cableado antes de escribir en los 3 downloaders (torrent: DataDir+totalBytes; debrid: outputDir+restantes; usenet: outputDir+totalBytes solo en fresh). Reserva por `SetMinFreeBytes` desde `downloads.min_free_disk_mb` (default 2048 MiB). `manager` falla sin fallback en disco lleno (otra fuente llena el mismo disco). Fix latente: `formatBytes` paniqueaba ≥1PB (array hasta TB) → +PB/EB+clamp.
-- **WEB**: `/api/internal/download` rechaza 507 `INSUFFICIENT_DISK` antes de crear la tarea si `diskFreeBytes - sizeBytes < 2 GiB` (reserva = default agente). Solo single-file torrent + agente online (telemetría de disco ya fluía). Saltado: stream, usenet, episodios (sizeBytes=pack completo → falso reject), agente offline. `DownloadButton` muestra estado `diskfull` (i18n 7 locales, namespace torrent). Bajo unarr el endpoint está fuera del allowlist → unarr solo streamea; el pre-flight del agente cubre sus descargas.
-- **Tests/smoke**: Go `diskspace_test` (Statfs real vía TempDir: enough/insufficient/reserve/unknown/bad-dir). Web reject no e2e-smokeable en el dev box (es unarr → endpoint 404); verificado por build+typecheck+lógica. /critico 2 revisores → 2 bugs reales (guard sin `health.online`; falso reject en season packs) + 4 clarity.
-
-### Hueco medio — Características del fichero + thumbnails bajo demanda ✅ CERRADO (2026-05-31)
-Panel "ver características del fichero" (ruta + mediainfo completa: codec/HDR/bit-depth/tracks audio+subs/tamaño/duración — ya en DB vía ffprobe, solo faltaba surface) + tira de fotogramas extraídos en vivo por el agente.
-- **CLI**: `GET /thumbnail?p=&pos=&w=&t=` en el stream server (ffmpeg `-ss ` antes de `-i`, `-frames:v 1`, MJPEG a stdout). Token scope `thumb:` (mismo HMAC que `/stream`/`/hls`; web mintea, agente verifica; vector cross-lang Go↔TS pinneado). Clamp a fichero regular, 404-sin-oracle, timeout 20s. `ffmpegPath` cableado en `daemon.go`. Floor `0.13.0`.
-- **WEB**: endpoints bajo `/api/internal/stream/` (permitido en unarr; `/api/internal/library` NO) — `file-details` (mediainfo + URLs de frames vía funnel HTTPS) + `owned-files` (lista mínima por contentId, solo items con ffprobe). Lógica pura testeada en `src/lib/stream/thumbnails.ts`. Modal compartido `FileDetailsModal`/`useFileDetails` con skeleton + carga progresiva ("Generando X/N…") + fallback por frame. Gating `supportsThumbnails`/`THUMBNAIL_MIN_VERSION`.
-- **Alcance en ambas marcas**: torrentclaw → acción en los 3 builders de menú de biblioteca (`fileInfoMenuItem` compartido). unarr → `UnarrFileDetailsButton` en `/title/` (la biblioteca unarr son estanterías, no `LibraryPage`). Modal reutiliza labels neutrales (namespace `library`, no `torrent`) → marca limpia.
-- **Tests/smoke**: Go (token vector, args, 400/404/503, stub-ffmpeg success) + web (resolveThumbnails, parity, version gate, i18n 7 locales). Smoke real contra biblioteca local 4K (Frankenstein, HEVC DV+HDR10): ffmpeg extrae JPEG válido, modal unarr muestra mediainfo + 5 frames vía funnel. /critico 4 revisores → 5 fixes (clipboard promise, dedup posiciones short-clip, tipos compartidos, guard videoInfo, helper menú).
-
----
-
-## ESTADO POR HUECO
-
-### Hueco #1 — Auth de stream
-**Estado:** 🟡 en progreso (iniciado 2026-05-31).
-
-**Enfoque elegido** (mejora sobre el plan previo, menor blast radius — sin migración DB):
-token **HMAC stateless minteado por el propio agente**. El agente ya construye las
-stream URLs que reporta a la web (`daemon.go` → `streamSrv.URLsJSON()`), así que
-puede firmar el token, embeberlo en la URL, y verificarlo en cada request — la web
-es passthrough (cambio web ~nulo).
-
-- Secreto: 32 bytes random en memoria del daemon (rota al reiniciar).
-- Token: `.`, TTL 6h.
-- `/stream` + VLC: token en query `?t=`; scope `"stream"`.
-- `/hls`: token en **path** `/hls///`; scope `"hls:"`.
- Los URIs hijos de los playlists son **relativos** → el token se propaga solo a
- segmentos/subs sin reescribir playlists.
-- **Loopback exento** (mpv/vlc local + health-probe siguen funcionando; el token solo
- gatea acceso remoto LAN/Tailscale/Public/funnel).
-- Config `require_stream_token` (default **true**, seguro por defecto).
-
-**Hecho (CERRADO 2026-05-31):**
-
-CLI (`torrentclaw-cli`):
-- `internal/engine/stream_token.go` (nuevo): `mintStreamToken`/`verifyStreamToken` (HMAC-SHA256, constant-time), `newStreamSecret` (32 bytes; **fail-hard** si crypto/rand falla, sin fallback débil).
-- `internal/engine/stream_server.go`: secreto + `requireToken` en StreamServer; `/stream` y `/hls` verifican el token; `URLsJSON`/`hlsBaseURLs`/`URL()` tokenizan; `StreamSecretHex()`; **sin exención de loopback**; `/playlist.m3u` ya no auto-mintea (cerrado el oracle).
-- `internal/config/config.go`: `require_stream_token` (default true).
-- `internal/agent/{types,daemon}.go` + `internal/cmd/daemon.go`: el agente reporta el secreto en register **solo si enforcing**.
-- Tests: `stream_token_test.go` (mint/verify/expiry/tamper/scope/secret, handler /stream + /hls, **vector de paridad cross-lenguaje**).
-
-WEB (`torrentclaw-web`):
-- `src/lib/stream-token.ts` (nuevo): minter HMAC en TS (paridad byte a byte con Go, guard de clave 64-hex).
-- `src/app/api/internal/stream/session/route.ts`: `buildHlsUrls` inyecta el token de path usando el secreto del agente.
-- `src/lib/db/schema.ts` + migración `0134_grey_chat.sql`: columna `agent_registration.stream_secret` (ADD COLUMN nullable, segura).
-- `src/app/api/internal/agent/register/route.ts` + `src/lib/services/agent.ts`: valida (64-hex) + persiste + expone en `getAgentHealth`.
-- Tests: `tests/unit/stream-token.test.ts` (paridad + guard).
-
-**Revisión adversarial** (workflow 4 dimensiones) → 1 crítico + 3 high corregidos antes de cerrar:
-- **CRÍTICO**: la exención de loopback dejaba el **funnel CloudFlare** sin protección (cloudflared proxya tráfico público vía `localhost` → todo el funnel llegaba como loopback). **Fix: eliminada la exención.** Toda URL entregada ya va tokenizada, así que ningún cliente legítimo se rompe; el funnel ahora lleva el token en la URL y verifica.
-- **HIGH** `/playlist.m3u` era oracle de tokens (fallback self-minting) → **fix: 404 sin streamUrl**.
-- **HIGH** gate de version-skew mal señalizado (el agente reportaba el secreto aunque enforcement=off) → **fix: reportar solo si enforcing**.
-- **HIGH** new-agent+old-web rompe HLS remoto → **mitigación por orden de deploy (ver abajo)**, sin tolerar tokenless (no reabrir el agujero).
-
-**Verificación:** CLI `go build/vet/test ./...` ✓; WEB typecheck+lint+2325 unit ✓; paridad cross-lenguaje verificada en ambos sentidos.
-
-> ⚠️ **ORDEN DE DEPLOY (obligatorio):** desplegar **primero el WEB** (columna `stream_secret` + minteo HLS), **luego** publicar el binario del agente. Un agente nuevo (enforce por defecto) contra un web viejo (sin minteo HLS) rompería el HLS remoto. El web es retrocompatible (agente viejo sin secreto → URLs sin token). Smoke real de extremo a extremo (daemon + funnel + navegador) **pendiente de hacer con un agente desplegado** — los tests cubren mint/verify/handlers y la paridad, no el round-trip cloudflared en vivo.
-
----
-
-### Hueco #2 — Debrid en el path de streaming
-**Estado:** ✅ CERRADO (2a+2b+2c, 2026-05-31).
-
-**CERRADO 2c (2026-05-31):** fallback mid-stream, alcance = **refresh de URL
-debrid** (decisión del usuario; el swap cross-source torrent↔debrid se difiere —
-caso raro, gran complejidad). La preferencia cache-fast (preferir debrid
-cacheado sobre torrent en streaming) ya la daban 2a/2b por orden de resolución.
-Los links debrid caducan; una peli larga sobrevive al link → al detectar expiry
-(401/403/404/410 en direct-play, o salida de red de ffmpeg en HLS) el agente
-re-resuelve (mismo info_hash → link fresco) y reanuda sin reiniciar.
-- WEB: endpoint `POST /api/internal/agent/stream-url` (withAgentAuth) →
- re-resuelve + actualiza fila + devuelve URL. Guard: sesión debrid viva
- (`direct_url IS NOT NULL`). 409 sin sesión, 410 si re-resolución falla.
-- CLI: `agentClient.RefreshStreamURL`; `debridFileProvider` URL mutable bajo
- mutex + reader refresca en expiry (bounded 1+1) + **coalescing singleflight**
- (N readers del `` → 1 re-resolución); HLS refresca `s.liveURL` (guarded,
- cfg inmutable → race-free con el seek-restart del handler HTTP) antes del
- auto-restart de ffmpeg.
-- Validado: reader refresh + coalescing unit-tested (incl. -race); endpoint
- e2e contra AllDebrid real (URL fresca + fila). El swap torrent↔debrid queda
- como mejora futura si aparece demanda.
-
-**CERRADO 2b (2026-05-31):** HLS-desde-URL para contenido debrid no-nativo
-(mkv/HEVC/…). ffmpeg lee la URL debrid directa (`-i ` + flags de red
-`-reconnect*`/`-rw_timeout`) y transcodifica a HLS; el seek reinicia ffmpeg con
-`-ss` antes de `-i` (input-seek vía Range). Cache de segmentos por `CacheID`
-(info_hash) → replay hace cache-HIT pese a que la URL cambia cada resolución.
-Validado e2e contra AllDebrid real: mkv HEVC x265 → h264_nvenc desde la URL →
-Chrome reproduce 1080p vía hls.js, subtítulos extraídos del mkv remoto. Bump
-CLI 0.11.0→0.12.0 (gate `DEBRID_HLS_MIN_VERSION`). Ficheros: CLI
-`engine/hls.go` (SourceURL/CacheID/sourceRef + flags red), `cmd/daemon.go`
-(branch 2b + helper `startHLSPlayback`), `engine/hls_cache.go` (`KeyForID`),
-`library/mediainfo/ffprobe.go` (no enmascarar errores de URL). WEB
-`stream/debrid-stream-source.ts` (playMethod direct|hls por contenedor),
-`services/agent-version-compare.ts` (`supportsDebridHls`).
-Limitación: solo audio default (raw debrid sin UI de pistas); subs bitmap (PGS)
-no soportados (igual que HLS local). Si AllDebrid no marca "ready" al primer
-addMagnet → fallback torrent (sin callejón).
-
-**CERRADO 2a (2026-05-31):** debrid como fuente de `/stream` (direct-play),
-validado e2e contra AllDebrid real (cuenta hello@torrentclaw.com): play de un
-infoHash cacheado mp4 → web resuelve la DirectURL → agente sirve `/stream` por
-GETs ranged → Chrome reproduce el mp4 1080p real (incluido seek a offset alto
-para el moov de un fichero sin faststart). CLI bump 0.10.0→0.11.0 (binario local,
-sin publicar). Fichero clave: `internal/engine/stream_source_debrid.go`.
-- CLI: `StreamSession.DirectURL`; `debridFileProvider` (`io.ReadSeekCloser` sobre
- HTTP Range, Seek sin red + GET lazy + reopen-on-seek + HEAD para tamaño +
- nombre derivado de URL para Content-Type correcto); branch en
- `daemon.OnStreamSession` (DirectURL presente → provider en goroutine →
- SetFile → MarkSessionReady), antes de validar filePath y sin ffmpeg.
-- WEB: columna `streaming_session.direct_url` (mig 0137) + índice
- `idx_debrid_cache_info_hash` (mig 0138, getHashCacheTier filtra por info_hash);
- helper `resolveDebridStreamSource` (honesty gate: sin fichero local + infoHash
- + agente ≥0.11.0 + `getHashCacheTier`==="verified" + container mp4/m4v +
- audioIndex -1 + !forceTranscode → resuelve DirectURL, playMethod="direct",
- quality "original"); gate de versión `DEBRID_STREAM_MIN_VERSION`/
- `supportsDebridStream`; `getPendingStreamSessions` emite `directUrl` + fallback
- fileName/fileSize vía join a `torrent` (cubre el caso HEAD-falla del provider).
-- Player: sin cambios — reusa el path direct-play del hueco #3 (playMethod=direct
- + streamUrls).
-- Limitación 2a (honesta): solo contenido debrid mp4/m4v browser-native; mkv/HEVC
- debrid → fallback a torrent hasta 2b (HLS-desde-URL). Si AllDebrid no marca el
- torrent "ready" al primer addMagnet → fallback a torrent (sin callejón).
-
-**Diseño original (2b/2c siguen vigentes):**
-
-**Problema (confirmado en el análisis):** hoy `debrid` es **solo descarga**
-(`engine/debrid.go` baja la `DirectURL` HTTPS resuelta server-side). El
-streaming es **100% torrent**: `daemon.OnStreamSession` arma el provider desde
-`sess.FilePath`/`sess.InfoHash`/`sess.TaskID` y `StreamSession` **no lleva
-DirectURL**. La promesa "play instantáneo cache-fast por debrid" no ocurre.
-
-**Arquitectura de providers (lo que ya hay):** `FileProvider{ NewFileReader(ctx)
-io.ReadSeekCloser; FileName(); FileSize() }`. Implementaciones: `torrentFileProvider`,
-`diskFileProvider`, `StreamEngine`. El /stream sirve un `FileProvider` via
-`http.ServeContent` (range/seek). El HLS arranca una `HLSSession` desde una ruta
-de fichero (ffmpeg `-i `).
-
-**Diseño por fases (de menos a más riesgo):**
-
-- **Fase 2a — debrid como fuente de /stream (direct-play).** *Slice completo y
- acotado.*
- 1. Añadir `DirectURL string` a `StreamSession` (web→agente) y a su validación.
- 2. Nuevo `debridFileProvider` (`FileProvider`): `NewFileReader` devuelve un
- `io.ReadSeekCloser` que hace **GET con Range** contra la `DirectURL` (debrid
- ya soporta Range, ver `debrid.go`); `FileSize` via HEAD o `sess.FileSize`;
- `Seek` traducido a `Range:`. Reutilizar la lógica de `debrid.go` (416,
- Content-Range, reintentos).
- 3. En `OnStreamSession`: si `sess.DirectURL` presente → `debridFileProvider`
- → `SetFile`. (Direct-play; el navegador hace range sobre el provider.)
- 4. Web: al crear la sesión de stream, si el contenido está **cacheado en
- debrid**, resolver la `DirectURL` server-side (como en descargas) e incluirla
- en el `StreamSession`. Señal de cache: `debridCacheStatus` fresh (ya existe).
- 5. Tests: `debridFileProvider` con un httptest server que sirve Range; round-trip
- /stream con provider debrid.
-
-- **Fase 2b — HLS desde URL (transcode de fuentes debrid no-compatibles).**
- ffmpeg lee HTTP directo (`-i https://…`), así que `HLSSession` puede aceptar
- una URL como source en vez de una ruta. Mayor cambio en el pipeline HLS
- (timeouts, reintentos de red, headers). Permite transcodear contenido debrid.
-
-- **Fase 2c — selección cache-fast + fallback mid-stream ("sin callejones").**
- - Conciencia de cache en el agente o señal del web para **preferir debrid
- cacheado sobre torrent** cuando aplique (hoy `resolve.go:22` pone torrent
- primero).
- - **Fallback mid-stream**: si la fuente activa muere (peers a 0 / 5xx debrid),
- cambiar a la otra sin cortar la reproducción. Complejo (estado de sesión,
- re-seek). Es lo que de verdad cierra "play-anything sin callejones".
-
-**Ficheros a tocar:** CLI `internal/engine/{stream_server.go (provider), debrid.go,
-hls.go (2b)}`, `internal/agent/types.go` (+DirectURL), `internal/cmd/daemon.go`
-(wiring). WEB `src/app/api/internal/stream/session/route.ts` (resolver DirectURL +
-cache), `src/lib/services/agent.ts`.
-
-**Partes difíciles / riesgos:** ranged reader robusto sobre HTTP (reconexión,
-timeouts), HLS-desde-URL (red dentro de ffmpeg), y el fallback mid-stream (estado).
-Empezar por 2a (valor inmediato, riesgo bajo), 2b y 2c como iteraciones.
-
-**Mejora detectada:** `resolve.go:22` ordena `torrent > debrid > usenet`; para el
-diferenciador cache-fast convendría que, **cuando hay cache debrid confirmada**,
-el orden de STREAMING (no el de descarga) prefiera debrid.
-
----
-
-### Hueco #3 — Device-profile + direct-play + ABR
-**Estado:** 🔵 EN CURSO (2026-05-31). Análisis cerrado; fase 3a en implementación.
-
-**Problema (confirmado en el análisis):**
-- El path browser usa **HLS y SIEMPRE re-encoda**: `buildHLSFFmpegArgsAt`
- (`engine/hls.go`) pone `-c:v libx264|nvenc|…` + cadena de filtros completa
- (scale/format/setparams) + AAC, sin rama de copia. Un mp4 h264/aac 8-bit SDR
- que el navegador reproduciría tal cual se transcodifica entero. Coste de CPU
- puro desperdicio.
-- `DecideAction` + `diskFileSource`/`transcodeSource` (`engine/probe.go`,
- `engine/stream_source.go`) **son código muerto**: cero callers en producción,
- solo tests. Distinguen `passthrough/remux/remux-audio/transcode-video` y detectan
- 10-bit/HDR — la lógica de decisión ya existe, no está cableada.
-
-**Lo que ya hay y se reaprovecha:**
-- El agente ya expone **dos paths** en el StreamServer (puerto 11818):
- - `/stream` → sirve el fichero crudo con `http.ServeContent` (HTTP Range
- completo, sin ffmpeg, ya tokenizado). **Direct-play ya es posible aquí.**
- - `/hls//…` → transcode HLS.
-- El web **construye las URLs** (HLS hoy) desde la info de red del agente
- (`streamPort`, `tailscaleIp`, `lanIp`, `funnelUrl`, `streamSecret`) y **puede
- mintear tokens** (`mintStreamToken`, scope `stream` es constante). O sea: el web
- puede construir la URL `/stream?t=…` de direct-play él mismo.
-- `libraryItem` ya guarda del scan: `videoCodec`, `audioCodec`, `bitDepth`, `hdr`,
- `resolution`. Con el contenedor (extensión de `fileName`), el web tiene todo
- para decidir direct-play SIN re-probar.
-
-**Diseño por fases (de menos a más riesgo):**
-
-- **Fase 3a — direct-play passthrough para items de biblioteca.** *El web decide.*
- *Slice acotado, ambos sentidos de version-skew seguros vía gate de versión.*
- 1. WEB `decidePlayMethod({videoCodec,audioCodec,bitDepth,hdr,container})` →
- `"direct" | "hls"` (espeja la rama passthrough de Go `DecideAction`: solo
- `mp4/m4v` + `h264` + `aac` + 8-bit + SDR → direct; todo lo demás → hls).
- 2. WEB gate: `supportsDirectPlay(agentVersion)` (constante de versión mínima).
- Direct-play solo si el agente la soporta; si no → hls (sin regresión).
- 3. WEB sesión: en la rama `libraryItemPublicId`, seleccionar los campos codec;
- calcular `playMethod` (gated); persistirlo en `streamingSession.play_method`
- (migración aditiva, `db:generate`); devolver `playMethod` + `streamUrls`
- (`/stream?t=` minteadas por el web, lan/ts/funnel) en la respuesta.
- 4. WEB sync: `getPendingStreamSessions` emite `playMethod` al agente.
- 5. CLI: `StreamSession.PlayMethod string`; en `OnStreamSession`, si
- `PlayMethod=="direct"` → `streamSrv.SetFile(NewDiskFileProvider(path))` +
- `MarkSessionReady` (sin ffmpeg). Else → `StartHLSSession` (actual).
- 6. WEB player (`HlsStreamPlayer.tsx`): si `data.playMethod==="direct"` → usar
- `data.streamUrls` + attach nativo `` (mp4 = reproducible en todo
- navegador, sin hls.js). Else → flujo HLS actual.
- - **Limitación honesta:** solo cubre items de biblioteca (escaneados, con
- metadata codec). Raw `infoHash`/`taskId` → hls (sin probe). Cubrir esos
- casos = fase 3a-bis (el agente decide tras probar, reportando playMethod por
- `MarkSessionReady` — requiere extender el payload + SSE + diferir el attach
- del player al evento ready). Diferido por mayor superficie.
-
-- **Fase 3b — remux fMP4 progresivo vía /stream (ENFOQUE ELEGIDO 2026-05-31).**
- Caso `mkv` (u otro contenedor no-mp4) con h264 + aac + 8-bit + SDR: codecs ya
- browser-native, solo el contenedor estorba. `-c copy` evita el re-encode de vídeo.
- Descartado HLS-copy (duraciones de segmento variables vs manifiesto pre-render →
- rompe seek; arreglarlo = probe de keyframes lento o reescribir el núcleo HLS).
- **Enfoque:** ffmpeg `-c copy -movflags +frag_keyframe+empty_moov+default_base_moof
- -f mp4` mkv→fMP4 a fichero temporal **creciente**; servir ese fMP4 por **/stream**
- (mismo path direct-play 3a, attach nativo, sin hls.js, sin manifiesto).
- **Núcleo real (la parte no-trivial):** servir un fichero que **crece** mientras
- ffmpeg escribe. El `/stream` actual usa `http.ServeContent` (asume fichero completo
- y seekable). Hay que:
- - Resucitar/adaptar el `transcodeSource` muerto (`engine/stream_source.go`):
- ffmpeg→tmp creciente, `ReadAt` con bloqueo hasta que los bytes existan
- (`readBlockTimeout`), `EstimatedSize` = bitrate×duración para que la barra del
- player tenga timeline.
- - Un **responder de Range manual** en /stream para fuentes no-finales (en vez de
- `http.ServeContent`): leer `Range`, `ReadAt` la fuente, escribir 206 +
- `Content-Range` con el tamaño estimado. El path mp4-completo (3a) sigue usando
- ServeContent (rápido).
- - Caveat: seek-adelante a zona no-remuxada bloquea hasta que el copy la alcanza
- (copy es I/O-bound, rápido). Seek-atrás (bytes ya en disco) inmediato.
- **Plan de incrementos seguros:**
- - **3b-i (agente, dormido):** `remuxSource` + responder Range para fuentes
- crecientes, gateado tras `PlayMethod=="remux"` (que el web aún no envía) →
- commiteable sin romper nada, con tests.
- - **3b-ii (web+player):** `decidePlayMethod` devuelve `"remux"` para
- contenedor-no-mp4 + h264/aac/8-bit/SDR; player trata `playMethod != "hls"` igual
- que direct (streamUrls + attach nativo). Activa 3b. Mismo gate de versión.
- **Ficheros:** CLI `engine/stream_source.go` (remuxSource), `engine/stream_server.go`
- (range responder + provider creciente), `cmd/daemon.go` (branch `remux`),
- `engine/transcoder.go` (args `-c copy` fMP4). WEB `lib/stream/play-method.ts`
- (+"remux"), `stream/session/route.ts`, `HlsStreamPlayer.tsx` (`!= "hls"`).
-
- **CERRADO 2026-05-31:**
- - CLI 3b-i (`feat/unarr-agent` 4a12f13): `GrowingSource` + `NewRemuxSource`
- (reusa `transcodeSource`+`ActionRemux`, estimate = tamaño origen para copy);
- `StreamServer.SetGrowingFile` + `serveGrowing` (responder Range manual: 206
- con total estimado en `Content-Range`, body chunked mientras no-final, exact
- `Content-Length` al finalizar, bloqueo vía `ReadAt`); branch `remux` en
- `OnStreamSession`. Tests `parseByteRange`+`serveGrowing` (full/offset/bounded/
- estimate/HEAD/416). build+vet+test verdes.
- - WEB 3b-ii (`feat/unarr-brand` 10b7d602): `decidePlayMethod`→`"remux"` para
- codecs compatibles en contenedor no-nativo; ruta gatea remux como direct
- (versión, metadata, sin downscale, audioIndex -1); player trata `!= "hls"`
- como attach nativo. lint+typecheck+2334 unit OK.
- - **Smoke e2e (browser, mkv h264/aac 1080p):** `playMethod: remux`, `hlsUrls:
- null`; agente `[stream …] remux (copy) → fMP4`; `/stream` HEAD 200 + GET Range
- 206 con fMP4 válido (`ftyp iso6 mp41`+`moov`); browser reproduce 1080p nativo,
- duration leída del fMP4, **seek a 2min OK**, **0 reqs `/hls`**. ✓
- - **Bug cazado por el smoke:** la respuesta `created` de la ruta quedó en
- `playMethod === "direct" ? null` (en vez de `!== "hls"`) → devolvía `hlsUrls`
- para remux. Corregido (el player usaba streamUrls igual, pero inconsistente).
- - **Limitación:** seek-adelante a zona aún-no-remuxada bloquea hasta que el copy
- (rápido) la alcanza; seek-atrás inmediato. Audio no-default / subs-bitmap →
- siguen yendo por HLS (gate `audioIndex == -1`).
-
-- **Fase 3c — capability negotiation (device-profile).** El web envía
- `{maxHeight, codecs:[h264,hevc,av1], containers}` (de UA + `canPlayType`).
- `decidePlayMethod` se hace device-aware: p.ej. Safari/AppleTV que reproduce HEVC
- nativo → passthrough HEVC en vez de transcode HEVC→h264. Reemplaza el heurístico
- UA-burdo de `resolveAutoQuality`. Web+CLI.
-
-- **Fase 3d — ABR.** ABR multi-rendition real **DESCARTADA**: N pipelines ffmpeg
- simultáneos = N× CPU para 1 espectador (mata NAS/Pi), y no aplica a los paths
- copy (direct/remux = 1 bitrate). Resuelto como **3d-lite (auto-downshift)**:
- el player ya tenía sondeo de ancho de banda + recomendación + selector manual;
- 3d-lite automatiza la bajada — buffering sostenido 10s → siguiente calidad menor
- (nueva sesión a bitrate menor), progresivo hasta 480p. Reusa
- `recommendLowerQuality`/`setQuality`. `setQuality(.., {persist:false})` para no
- pisar la preferencia del usuario por un stall transitorio. **CERRADO (web 8bf8e416)**;
- smoke en Chrome (Slow-3G + seek → consola `auto-downshift 720p → 480p`, nueva
- sesión reproduce). Hallazgo: este Chrome reproduce HLS **nativo** (como Safari);
- hls.js es fallback.
-
-**Ficheros a tocar (3a):** CLI `internal/agent/types.go` (+PlayMethod),
-`internal/cmd/daemon.go` (branch SetFile vs HLS). WEB
-`src/lib/services/agent-version-compare.ts` (gate), `src/lib/stream/play-method.ts`
-(nuevo), `src/lib/stream-token.ts` (scope stream), `src/lib/db/schema.ts` +
-migración (`streamingSession.play_method`), `src/app/api/internal/stream/session/route.ts`
-(decisión + URLs), `src/lib/services/agent.ts` (`getPendingStreamSessions` emite
-playMethod), `src/components/stream/HlsStreamPlayer.tsx` (attach nativo).
-
-**Seguridad de version-skew (3a):**
-- Web nuevo + agente viejo: gate `supportsDirectPlay` ve versión vieja → hls. ✓
-- Web viejo + agente nuevo: web nunca manda `direct` → agente hls. ✓
-- Campo `PlayMethod` desconocido en agente viejo = ignorado por el unmarshal. ✓
-
-**Empezar por 3a** (valor inmediato — el caso primario de unarr es la biblioteca
-local escaneada; mp4-h264-aac es común en web-dl/YIFY). 3b/3c/3d como iteraciones.
-
-**Hecho (Fase 3a CERRADA 2026-05-31):**
-- CLI (`feat/unarr-agent` c8d7c4b): `StreamSession.PlayMethod`; `OnStreamSession`
- ramifica `direct` → `SetFile(NewDiskFileProvider)` + `MarkSessionReady` (sin
- ffmpeg, antes del check de ffmpeg para funcionar con transcode off). `go build`
- + `vet` + tests verdes.
-- WEB (`feat/unarr-brand` 636fbe59): `decidePlayMethod()` (espeja la rama
- passthrough de Go, conservador) + test unitario; gate `supportsDirectPlay`
- (`DIRECT_PLAY_MIN_VERSION = 0.10.0`); decisión en la ruta de sesión (solo
- library item + sin downscale + `audioIndex == -1`); `buildStreamUrls` mintea
- token scope `stream` (paridad Go); `streaming_session.play_method` (migración
- 0135) emitido al agente vía `getPendingStreamSessions`; player ramifica a
- `` nativo. lint + typecheck:all + 2333 unit + build (brand unarr) OK.
-- Revisión adversarial (correctness + security/parity, 2 agentes): **0 hallazgos
- bloqueantes**. Token parity y version-skew (ambos sentidos) confirmados.
-
-**Correcciones de la revisión propia (3a):** direct-play exige `audioIndex == -1`
-(servir el fichero entero no respeta una pista de audio no-default elegida por el
-usuario → esos casos van a HLS con `-map 0:a:N`).
-
-**Smoke e2e (3a) — PASADO 2026-05-31** (agente dev 0.10.0 build local + item de
-biblioteca mp4-h264-aac `/mnt/nas/peliculas/.../Tangled.Ever.After...mp4` + browser):
-- POST `/api/internal/stream/session` → `playMethod: direct`, `streamUrls` con
- `/stream?t=` (token web scope `stream`), `hlsUrls: null`. ✓
-- Agente: `[stream …] direct-play: Tangled…mp4` (SetFile, sin ffmpeg). ✓
-- `/stream`: HEAD 200 `video/mp4` `Content-Length 128321419`; GET Range 0-1023 →
- 206 + bytes mp4 reales (`ftyp isom…avc1`). **Token web verificado por Go → paridad
- cross-lenguaje confirmada en vivo** (sin token → 404). ✓
-- CORS desde origen browser (`localhost:3030`): ACAO correcto, preflight 204. ✓
-- Browser: `.currentSrc` = `/stream?t=…` (NO `/hls`), `readyState 4`,
- reproduciéndose, 1920×1080 nativo, **13 reqs `/stream`, 0 `/hls`**, attach
- **nativo** (`[hls] (native) loadedmetadata`, sin hls.js). Telemetría
- metric/progress OK. ✓
-
-**Bug pre-existente encontrado + arreglado durante el smoke** (web 764f5b01): el
-allow-list de la marca unarr (`src/lib/branding/routes.ts`) NO incluía
-`/api/internal/agent` ni `/api/internal/stream` → en unarr el agente daba 404 al
-registrar y el player 404 al crear sesión. **El streaming + agente de unarr estaban
-rotos de raíz.** Añadidos al allow-list (superficie del agente/media propio del
-usuario, cero superficie torrent).
-
-**Nota de release:** versión bumpeada a **0.10.0** (`version.go`, CLI 944d652) — solo
-binario local para el smoke, **sin publicar nada**. `DIRECT_PLAY_MIN_VERSION = 0.10.0`
-(web 52d958f0). Al publicar la release real del CLI, debe ser >= 0.10.0.
-
-**Backlog detectado en 3a (baja prioridad):**
-- `streaming_session.transport` queda `"hls"` también para sesiones direct
- (el enum `TRANSPORT_VALUES` solo tiene `"hls"`); telemetría imprecisa, no bug.
- Añadir `"direct"` al vocabulario cuando se toque la métrica.
-- Modelo single-viewer: dos plays direct simultáneos → el último `SetFile` gana;
- el tab viejo reproduciría contenido nuevo en silencio (HLS al menos 404ea).
-- Direct-play no aplica `audioIndex` ni extrae subs a WebVTT (usa pistas
- embebidas vía `` nativo); subs bitmap no se ven. Aceptable en 3a.
-- Listener `loadedmetadata {once:true}` del attach nativo no se limpia
- explícitamente en cleanup (idempotente, impacto nulo).
-
-**Fase 3c CERRADA 2026-05-31** (capability-negotiation, alcance ampliado):
-- CLI (`feat/unarr-agent` 957d499): `NewRemuxSource` copia el vídeo para cualquier
- codec decodificable: h264, o HEVC/AV1 si el dispositivo lo declara. HEVC se muxea
- con `-tag:v hvc1` (Apple lo exige). Audio no-aac (ac3/eac3/dts) se transcodifica a
- aac copiando el vídeo (`ActionRemuxAudio`) → cubre el muy común **h264+ac3 mkv**.
-- WEB (`feat/unarr-brand` b0681d99): player sondea `canPlayType` (`detectDeviceCaps`)
- y envía `{hevc,av1}` en el POST; `decidePlayMethod(p, caps)` device-aware:
- HEVC/AV1 → `remux` solo si el dispositivo decodifica; audio no-aac ya no fuerza
- `hls`. Tests caps actualizados (10).
-- **Smoke e2e:** caps gate (sin caps→`hls`, con caps→`remux`); h264+ac3 remux
- reproduce en Chrome (audio transcodeado, vídeo copiado); retag verificado por
- ffprobe (`codec_name=hevc`, `codec_tag_string=hvc1`); **HEVC reproduce en iPhone
- Safari real (Tailscale) — confirmado por el usuario.** ✓
-- **Caveat:** playback HEVC en Apple no se puede smokear en este host (Chrome-Linux
- no decodifica HEVC; Mac-mini Safari por SSH bloqueado por TCC: Automation +
- Screen Recording necesitan click GUI). Verificado vía iPhone del usuario.
-
-**Diagnóstico time-to-first-frame (2026-05-31)** (instrumentación en 957d499:
-timers `probe`/`spawn`, `first fMP4 bytes after`, `serveGrowing blocked`):
-- Agente NO es el cuello: probe 16–98ms, spawn 1–194ms, primer byte fMP4 ~201ms,
- **0 bloqueos** en `serveGrowing` (LAN ni remoto). Remux `-c copy` completo de un
- fichero de ~780MB en ~16s (limitado por lectura NAS).
-- `moov` al frente (empty_moov OK) → el player no busca metadata al final.
-- Cliente (Chrome/LAN): POST→primer request ~480ms (sobre todo carga de página).
-- **1ª reproducción lenta = warm-up de red (Tailscale); 2ª/3ª rápidas** (confirmado
- por el usuario). No es un problema de código.
-- Player YA da feedback (fases `loading-meta`/`probing-transport`/`playing` +
- overlay "Preparando…" + spinner de buffering + mensaje stuck >10s). El "sin
- feedback" del test fue por usar URL cruda (sin UI), no el flujo real.
-- **Conclusión:** sin optimización de código necesaria. Arranque garantizado-instante
- = **hueco #4 (pre-transcode)**: dejar el remux/encode hecho antes del play.
-
----
-
-### Hueco #4 — Pre-transcode (transcode-on-download)
-**Estado:** 🔵 DISEÑADO (2026-05-31), pendiente de implementar.
-
-**Qué es:** al completar una descarga (o import a biblioteca), procesar en
-background para que la reproducción sea **instantánea** sin transcode en vivo.
-Es una optimización: si no terminó cuando el usuario da play → fallback al
-transcode en vivo (HLS actual). **Nunca bloquea.**
-
-**Sinergia con lo existente (clave — gran parte de la infra ya está):**
-- `hls_cache.go`: un encode HLS completo se cachea y el cache-HIT lo sirve
- instantáneo (cero ffmpeg). Pre-transcode = poblar esa cache antes del play.
-- `stream-prewarm.ts` + `createPrewarmSession`: ya lanza un encode HLS de la
- siguiente ep en background. Pre-transcode = generalizar prewarm a "cualquier
- download, configurable", + producir también el artefacto direct-play (3a).
-- Por tanto el trabajo NUEVO es: (1) disparador on-download-complete, (2)
- superficie de config en web, (3) gobernanza de recursos + cola, (4) decisión
- "qué producir" (remux mp4 para 3a vs HLS cache vs nada si ya es native).
-
-**Opciones a exponer en la web (set propuesto):**
-
-1. **Activación + disparador**
- - Toggle global on/off (default OFF — CPU/disco intensivo).
- - Disparador: al completar descarga / al escanear-importar / manual
- ("optimizar ahora" por item) / programado (ventana horaria).
- - Default recomendado: on-download-complete, pero solo en ventana idle + sin
- stream en vivo activo.
-
-2. **Qué producir (target) — modo Auto recomendado (por probe):**
- - ya browser-native (mp4 h264/aac 8-bit SDR) → **nada** (3a lo sirve crudo).
- - solo contenedor incompatible (mkv h264/aac) → **remux** a mp4 (barato, sin
- re-encode; habilita 3a direct-play). *(necesita 3b para el manifiesto.)*
- - codec incompatible (HEVC/AV1/10-bit/HDR) → **transcode** a H.264 (caro).
- - Modos: solo-remux / remux+transcode / forzar H.264 universal.
- - Formato salida: mp4 direct-play (seek nativo) vs HLS cache (multi-network)
- vs ambos. Recomendado: mp4 si compatible, HLS si requiere transcode.
-
-3. **Calidad**
- - Mantener original (passthrough cuando se pueda) / cap 1080p / ladder ABR
- (480/720/1080/original — encaja con 3d).
- - "Solo transcodear si ayuda" (no tocar lo ya compatible).
-
-4. **Selección / alcance**
- - Todo / solo biblioteca (pelis+series) / solo lo problemático (p.ej. solo
- 4K HEVC, dejar h264).
- - Solo watchlist / recién añadido / todo. Reglas por carpeta de biblioteca.
-
-5. **Gobernanza de recursos (lo más importante — es pesado):**
- - Concurrencia (N transcodes paralelos, default 1).
- - HW accel si disponible (nvenc/qsv/vaapi); cap de threads CPU.
- - Ventana horaria (solo idle, p.ej. 02:00–08:00).
- - **Pausar cuando hay stream en vivo** (no pelear por CPU con la reproducción).
- - Prioridad de cola (watchlist primero / más pequeño primero / más nuevo).
- - (Laptops) solo con AC / no en batería.
-
-6. **Disco / retención (liga con el hueco medio de espacio en disco):**
- - Dónde guardar (cache dir) + tamaño máx + evicción LRU (ya parcial en cache).
- - Mantener SIEMPRE el original; el transcode es artefacto adicional.
- - TTL: borrar pre-transcode no visto en N días; pin a visto/favorito.
- - Re-transcodear al cambiar la config de calidad (invalidación).
-
-7. **UX / estado**
- - Cola + progreso por item en la web ("Optimizando para reproducción
- instantánea…"). Badge en library card: "listo para play instantáneo" vs
- "se transcodificará al reproducir". Notificación al terminar (opcional).
-
-8. **Fallback / límites**
- - Si no terminó a tiempo → transcode en vivo (HLS). Nunca bloquea el play.
- - Solo ficheros locales en disco (no debrid/torrent sin bajar).
-
-**MVP recomendado (fase 4a):** toggle on/off + disparador on-download-complete +
-modo Auto (remux-si-compatible / transcode-si-no) + concurrencia 1 +
-pausar-si-stream-activo + reusar `hls_cache` + badge "listo". El resto (ladder
-ABR, ventanas horarias, reglas por carpeta, TTL avanzado, formato mp4 vs HLS
-configurable) en fases 4b/4c.
-
-**Dependencias:** el camino mp4/remux depende del hueco #3 (3a ya hecho; 3b para
-el remux-a-mp4 con manifiesto correcto). El camino HLS-cache es implementable ya
-(reusa cache + prewarm). La gobernanza (pausar-si-stream) necesita señal de
-"stream activo" en el daemon (la hay: `streamSrv.HasFile()` + registro HLS).
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/docker-compose.yml b/docker-compose.yml
index 60446db..5f49fcf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,77 +1,48 @@
-# unarr — TorrentClaw agent
-#
-# Quick start:
-# 1. Copy this file to any directory.
-# 2. Set UNARR_API_KEY to your key (Settings → API Keys on torrentclaw.com).
-# 3. Set DOWNLOAD_DIR to your media folder (absolute path).
-# 4. Run: docker compose up -d
-#
-# Get your API key: https://torrentclaw.com/settings/api-keys
-# Full docs: https://torrentclaw.com/unarr
-
services:
unarr:
+ build:
+ context: ..
+ dockerfile: unarr/Dockerfile
image: torrentclaw/unarr:latest
- pull_policy: always # always pull on `up` so you stay on the latest release
container_name: unarr
restart: unless-stopped
+ user: "1000:1000"
- # host network is required for:
- # - streaming to reach your TV / mobile / other LAN devices (port 11818)
- # - HLS transcode server (port 11819)
- # - Tailscale connectivity (if you use it)
- # On macOS / Windows Docker Desktop, replace with `ports` mapping (see below).
- network_mode: host
-
- environment:
- # --- Required ---
- - UNARR_API_KEY=${UNARR_API_KEY:?Set UNARR_API_KEY in .env or export it}
-
- # --- Optional ---
- # Server URL — change only if you run a self-hosted TorrentClaw instance
- - UNARR_API_URL=${UNARR_API_URL:-https://torrentclaw.com}
- - TZ=${TZ:-UTC}
+ # Read-only root filesystem — only volumes are writable
+ read_only: true
+ tmpfs:
+ - /tmp:size=64m,mode=1777
volumes:
- # Config: config.toml is auto-created here on first run.
- # After first start, edit this file to set organize paths, quality, etc.
- - ${CONFIG_DIR:-./config}:/config
-
- # Downloads: where finished media is saved.
- # Set DOWNLOAD_DIR in .env or export it before running.
- - ${DOWNLOAD_DIR:?Set DOWNLOAD_DIR to your media folder}:/downloads
-
- # Data: piece-completion DB, HLS cache, DHT nodes.
- # Named volume keeps this off your media drive (avoids NFS locking issues).
+ # Config: your config.toml lives here
+ - ./config:/config
+ # Downloads: finished media goes here
+ - ~/Media:/downloads
+ # Data: torrent metadata, piece DB, cache
- unarr-data:/data
- # --- NVIDIA GPU: hardware transcode (nvenc) ---
- # Uncomment on a host with an NVIDIA GPU + nvidia-container-toolkit. The
- # image already bundles an nvenc-enabled ffmpeg and sets
- # NVIDIA_DRIVER_CAPABILITIES=video,compute,utility, so this device
- # reservation is the only thing needed to enable HW transcode. Without a GPU
- # the same image falls back to software (libx264) automatically — leave it
- # commented. (docker run equivalent: add --gpus all)
- # deploy:
- # resources:
- # reservations:
- # devices:
- # - driver: nvidia
- # count: all
- # capabilities: [gpu]
- # # Optional: cap CPU/RAM for transcoding on shared hosts
- # limits:
- # memory: 2G
- # cpus: "4.0"
+ environment:
+ - TZ=${TZ:-UTC}
+ # Optional overrides (uncomment to use):
+ # - UNARR_API_KEY=tc_your_key_here
+ # - UNARR_API_URL=https://torrentclaw.com
- # --- macOS / Windows alternative (replace network_mode: host above) ---
- # network_mode: bridge
+ # Resource limits — adjust to your needs
+ deploy:
+ resources:
+ limits:
+ memory: 512M
+ cpus: "2.0"
+
+ # Torrent P2P needs host network or explicit port range
+ # Option A: host network (simplest, full P2P performance)
+ network_mode: host
+
+ # Option B: bridge network with port mapping (more isolated)
+ # Uncomment below and comment out network_mode above:
# ports:
- # - "11818:11818" # direct stream (VLC, download)
- # - "11819:11819" # HLS transcode (web player)
- # - "42069:42069" # BitTorrent incoming peers
- # Note: streaming will only reach devices on the same machine.
- # For LAN / Tailscale playback use a Linux host with network_mode: host.
+ # - "6881-6889:6881-6889/tcp"
+ # - "6881-6889:6881-6889/udp"
volumes:
unarr-data:
diff --git a/go.mod b/go.mod
index e5276b6..a47f6e3 100644
--- a/go.mod
+++ b/go.mod
@@ -10,15 +10,12 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/fatih/color v1.19.0
github.com/getsentry/sentry-go v0.44.1
- github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/huin/goupnp v1.3.0
github.com/olekukonko/tablewriter v1.1.4
github.com/spf13/cobra v1.10.2
- github.com/spf13/pflag v1.0.10
github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.43.0
- golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
)
@@ -116,6 +113,7 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@@ -129,6 +127,7 @@ require (
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect
diff --git a/go.sum b/go.sum
index 97bb150..d1c9fe6 100644
--- a/go.sum
+++ b/go.sum
@@ -207,8 +207,6 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
-github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
diff --git a/internal/acme/acme.go b/internal/acme/acme.go
deleted file mode 100644
index d8f77c7..0000000
--- a/internal/acme/acme.go
+++ /dev/null
@@ -1,136 +0,0 @@
-// Package acme handles the agent side of the per-agent direct-TLS feature
-// (plex.direct model). The agent generates and keeps its private key LOCALLY,
-// builds a CSR for *..agent.unarr.app, and sends only the CSR to the
-// web-side broker (which runs the ACME order against Let's Encrypt via DNS-01
-// and returns the signed chain). The key never leaves the machine.
-//
-// File layout under the agent state dir:
-//
-// certs/agent.key ECDSA P-256 private key (PEM, persisted across renewals)
-// certs/agent.crt issued certificate chain (PEM, hot-reloaded by the stream server)
-package acme
-
-import (
- "crypto/ecdsa"
- "crypto/elliptic"
- "crypto/rand"
- "crypto/x509"
- "crypto/x509/pkix"
- "encoding/hex"
- "encoding/pem"
- "fmt"
- "os"
- "path/filepath"
- "time"
-)
-
-// GenerateHash returns a 32-hex-char (16-byte) high-entropy agent hash label.
-func GenerateHash() (string, error) {
- b := make([]byte, 16)
- if _, err := rand.Read(b); err != nil {
- return "", fmt.Errorf("generate agent hash: %w", err)
- }
- return hex.EncodeToString(b), nil
-}
-
-// Paths returns the key/cert file paths under the agent state dir.
-func Paths(dataDir string) (keyPath, certPath string) {
- dir := filepath.Join(dataDir, "certs")
- return filepath.Join(dir, "agent.key"), filepath.Join(dir, "agent.crt")
-}
-
-// loadOrCreateKey returns the agent's persistent EC key, creating + persisting
-// it on first use. Reused across renewals so the cert always matches the key.
-func loadOrCreateKey(keyPath string) (*ecdsa.PrivateKey, error) {
- if data, err := os.ReadFile(keyPath); err == nil {
- block, _ := pem.Decode(data)
- if block == nil {
- return nil, fmt.Errorf("agent.key is not valid PEM")
- }
- key, err := x509.ParseECPrivateKey(block.Bytes)
- if err != nil {
- return nil, fmt.Errorf("parse agent.key: %w", err)
- }
- return key, nil
- }
-
- key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- if err != nil {
- return nil, fmt.Errorf("generate EC key: %w", err)
- }
- der, err := x509.MarshalECPrivateKey(key)
- if err != nil {
- return nil, fmt.Errorf("marshal EC key: %w", err)
- }
- if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
- return nil, fmt.Errorf("mkdir certs: %w", err)
- }
- pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
- if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
- return nil, fmt.Errorf("write agent.key: %w", err)
- }
- return key, nil
-}
-
-// BuildCSR ensures the persistent key exists and returns a PEM CSR requesting
-// the wildcard *.. (plus the bare . so a
-// future non-wildcard use still validates). baseDomain e.g. "agent.unarr.app".
-func BuildCSR(dataDir, hash, baseDomain string) (csrPEM string, err error) {
- keyPath, _ := Paths(dataDir)
- key, err := loadOrCreateKey(keyPath)
- if err != nil {
- return "", err
- }
- wildcard := "*." + hash + "." + baseDomain
- base := hash + "." + baseDomain
- tmpl := &x509.CertificateRequest{
- Subject: pkix.Name{CommonName: wildcard},
- DNSNames: []string{wildcard, base},
- SignatureAlgorithm: x509.ECDSAWithSHA256,
- }
- der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
- if err != nil {
- return "", fmt.Errorf("create CSR: %w", err)
- }
- return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})), nil
-}
-
-// WriteCert persists the issued certificate chain atomically (temp file + rename)
-// so a concurrent reader (NeedsIssue, or the listener's GetCertificate reload)
-// can never observe a half-written PEM during a renewal.
-func WriteCert(dataDir, certPEM string) error {
- _, certPath := Paths(dataDir)
- if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
- return fmt.Errorf("mkdir certs: %w", err)
- }
- tmp := certPath + ".tmp"
- if err := os.WriteFile(tmp, []byte(certPEM), 0o644); err != nil {
- return fmt.Errorf("write agent.crt: %w", err)
- }
- if err := os.Rename(tmp, certPath); err != nil {
- return fmt.Errorf("rename agent.crt: %w", err)
- }
- return nil
-}
-
-// renewBefore is how long ahead of expiry we proactively renew.
-const renewBefore = 30 * 24 * time.Hour
-
-// NeedsIssue reports whether we should (re)request a cert: true when the cert is
-// missing, unparseable, expired, or within renewBefore of expiry.
-func NeedsIssue(dataDir string) bool {
- _, certPath := Paths(dataDir)
- data, err := os.ReadFile(certPath)
- if err != nil {
- return true
- }
- block, _ := pem.Decode(data)
- if block == nil {
- return true
- }
- cert, err := x509.ParseCertificate(block.Bytes)
- if err != nil {
- return true
- }
- return time.Now().Add(renewBefore).After(cert.NotAfter)
-}
diff --git a/internal/acme/acme_test.go b/internal/acme/acme_test.go
deleted file mode 100644
index cd62ae3..0000000
--- a/internal/acme/acme_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package acme
-
-import (
- "crypto/ecdsa"
- "crypto/elliptic"
- "crypto/rand"
- "crypto/x509"
- "crypto/x509/pkix"
- "encoding/pem"
- "math/big"
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestGenerateHash(t *testing.T) {
- h1, err := GenerateHash()
- if err != nil {
- t.Fatal(err)
- }
- if len(h1) != 32 {
- t.Errorf("hash len = %d, want 32", len(h1))
- }
- h2, _ := GenerateHash()
- if h1 == h2 {
- t.Errorf("two hashes collided: %s", h1)
- }
-}
-
-func TestBuildCSR(t *testing.T) {
- dir := t.TempDir()
- hash := "deadbeefdeadbeef"
- csrPEM, err := BuildCSR(dir, hash, "agent.unarr.app")
- if err != nil {
- t.Fatal(err)
- }
- // Key persisted.
- keyPath, _ := Paths(dir)
- if _, err := os.Stat(keyPath); err != nil {
- t.Errorf("key not persisted: %v", err)
- }
- // CSR parses + carries exactly the wildcard + base SANs.
- block, _ := pem.Decode([]byte(csrPEM))
- if block == nil {
- t.Fatal("CSR is not valid PEM")
- }
- csr, err := x509.ParseCertificateRequest(block.Bytes)
- if err != nil {
- t.Fatal(err)
- }
- want := map[string]bool{
- "*.deadbeefdeadbeef.agent.unarr.app": false,
- "deadbeefdeadbeef.agent.unarr.app": false,
- }
- for _, n := range csr.DNSNames {
- if _, ok := want[n]; !ok {
- t.Errorf("unexpected SAN: %s", n)
- }
- want[n] = true
- }
- for n, seen := range want {
- if !seen {
- t.Errorf("missing SAN: %s", n)
- }
- }
-
- // A second BuildCSR reuses the same key (cert must match the persistent key).
- before, _ := os.ReadFile(keyPath)
- if _, err := BuildCSR(dir, hash, "agent.unarr.app"); err != nil {
- t.Fatal(err)
- }
- after, _ := os.ReadFile(keyPath)
- if string(before) != string(after) {
- t.Errorf("key changed across BuildCSR calls — renewals would break")
- }
-}
-
-func TestNeedsIssue(t *testing.T) {
- dir := t.TempDir()
- // Missing cert → needs issue.
- if !NeedsIssue(dir) {
- t.Error("missing cert should need issue")
- }
-
- _, certPath := Paths(dir)
- if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
- t.Fatal(err)
- }
-
- writeSelfSigned := func(notAfter time.Time) {
- key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- tmpl := &x509.Certificate{
- SerialNumber: big.NewInt(1),
- Subject: pkix.Name{CommonName: "*.x.agent.unarr.app"},
- NotBefore: time.Now().Add(-time.Hour),
- NotAfter: notAfter,
- }
- der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
- pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
- if err := os.WriteFile(certPath, pemBytes, 0o644); err != nil {
- t.Fatal(err)
- }
- }
-
- // Fresh cert (90d) → no issue needed.
- writeSelfSigned(time.Now().Add(90 * 24 * time.Hour))
- if NeedsIssue(dir) {
- t.Error("fresh cert should not need issue")
- }
-
- // Within renew window (10d left) → needs issue.
- writeSelfSigned(time.Now().Add(10 * 24 * time.Hour))
- if !NeedsIssue(dir) {
- t.Error("near-expiry cert should need issue")
- }
-
- // Garbage → needs issue.
- _ = os.WriteFile(certPath, []byte("not a cert"), 0o644)
- if !NeedsIssue(dir) {
- t.Error("unparseable cert should need issue")
- }
-}
diff --git a/internal/agent/active_tasks.go b/internal/agent/active_tasks.go
deleted file mode 100644
index 694f28d..0000000
--- a/internal/agent/active_tasks.go
+++ /dev/null
@@ -1,105 +0,0 @@
-package agent
-
-import (
- "encoding/json"
- "os"
- "path/filepath"
- "sync"
-
- "github.com/torrentclaw/unarr/internal/config"
-)
-
-// activeTasksFilePathFn is overridable for testing.
-var activeTasksFilePathFn = func() string {
- return filepath.Join(config.DataDir(), "active-tasks.json")
-}
-
-// ActiveTaskStore persists the dispatch payloads (agent.Task) of in-flight
-// DOWNLOAD tasks so the daemon can re-submit them after a restart and have the
-// downloaders resume the partial data — torrent via the persisted
-// piece-completion DB, debrid via HTTP Range, usenet via its segment tracker.
-//
-// Distinct from LocalState (tasks.json), which holds transient status/progress
-// for syncing to the web; this holds the re-dispatch payload needed to restart
-// the work. An entry is added when a download starts and removed when it
-// reaches a genuine terminal state (completed / failed / cancelled) — but NOT
-// when the daemon is shutting down, so an interrupted download survives the
-// restart and resumes.
-type ActiveTaskStore struct {
- mu sync.Mutex
- tasks map[string]Task
-}
-
-// NewActiveTaskStore creates an empty store. Call Load() to hydrate it from disk.
-func NewActiveTaskStore() *ActiveTaskStore {
- return &ActiveTaskStore{tasks: make(map[string]Task)}
-}
-
-// Add records (or replaces) a task and persists the set.
-func (s *ActiveTaskStore) Add(t Task) {
- s.mu.Lock()
- defer s.mu.Unlock()
- s.tasks[t.ID] = t
- s.flushLocked()
-}
-
-// Remove drops a task and persists the set. No-op if absent.
-func (s *ActiveTaskStore) Remove(taskID string) {
- s.mu.Lock()
- defer s.mu.Unlock()
- if _, ok := s.tasks[taskID]; !ok {
- return
- }
- delete(s.tasks, taskID)
- s.flushLocked()
-}
-
-// Load reads the persisted tasks from disk into the store and returns them.
-// Returns nil on a missing or unreadable file (a fresh daemon has nothing to
-// resume). Safe to call once at startup before any Add/Remove.
-func (s *ActiveTaskStore) Load() []Task {
- data, err := os.ReadFile(activeTasksFilePathFn())
- if err != nil {
- return nil
- }
- var tasks []Task
- if json.Unmarshal(data, &tasks) != nil {
- return nil
- }
- s.mu.Lock()
- defer s.mu.Unlock()
- s.tasks = make(map[string]Task, len(tasks))
- for _, t := range tasks {
- if t.ID != "" {
- s.tasks[t.ID] = t
- }
- }
- out := make([]Task, 0, len(s.tasks))
- for _, t := range s.tasks {
- out = append(out, t)
- }
- return out
-}
-
-// flushLocked atomically writes the current set to disk. Caller holds s.mu.
-// Best-effort: a write failure is non-fatal (the in-memory set stays correct;
-// at worst a crash before the next flush loses one resume entry).
-func (s *ActiveTaskStore) flushLocked() {
- tasks := make([]Task, 0, len(s.tasks))
- for _, t := range s.tasks {
- tasks = append(tasks, t)
- }
- data, err := json.MarshalIndent(tasks, "", " ")
- if err != nil {
- return
- }
- path := activeTasksFilePathFn()
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return
- }
- tmp := path + ".tmp"
- if err := os.WriteFile(tmp, data, 0o644); err != nil {
- return
- }
- _ = os.Rename(tmp, path)
-}
diff --git a/internal/agent/active_tasks_test.go b/internal/agent/active_tasks_test.go
deleted file mode 100644
index 71382bd..0000000
--- a/internal/agent/active_tasks_test.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package agent
-
-import (
- "path/filepath"
- "testing"
-)
-
-// withTempStorePath points the store file at a temp location for the duration
-// of a test and restores the original afterward.
-func withTempStorePath(t *testing.T) {
- t.Helper()
- orig := activeTasksFilePathFn
- path := filepath.Join(t.TempDir(), "active-tasks.json")
- activeTasksFilePathFn = func() string { return path }
- t.Cleanup(func() { activeTasksFilePathFn = orig })
-}
-
-func TestActiveTaskStore_AddLoadRoundTrip(t *testing.T) {
- withTempStorePath(t)
-
- s := NewActiveTaskStore()
- s.Add(Task{ID: "a", InfoHash: "hashA", Title: "Movie A", Mode: "download"})
- s.Add(Task{ID: "b", NzbID: "nzbB", Title: "Show B"})
-
- // A fresh store hydrated from disk must see both.
- loaded := NewActiveTaskStore().Load()
- if len(loaded) != 2 {
- t.Fatalf("Load returned %d tasks, want 2", len(loaded))
- }
- byID := map[string]Task{}
- for _, tk := range loaded {
- byID[tk.ID] = tk
- }
- if byID["a"].InfoHash != "hashA" || byID["a"].Title != "Movie A" {
- t.Errorf("task a not round-tripped: %+v", byID["a"])
- }
- if byID["b"].NzbID != "nzbB" {
- t.Errorf("task b not round-tripped: %+v", byID["b"])
- }
-}
-
-func TestActiveTaskStore_Remove(t *testing.T) {
- withTempStorePath(t)
-
- s := NewActiveTaskStore()
- s.Add(Task{ID: "a", Title: "A"})
- s.Add(Task{ID: "b", Title: "B"})
- s.Remove("a")
- s.Remove("missing") // no-op
-
- loaded := NewActiveTaskStore().Load()
- if len(loaded) != 1 || loaded[0].ID != "b" {
- t.Fatalf("after Remove(a), Load = %+v, want only b", loaded)
- }
-}
-
-func TestActiveTaskStore_Overwrite(t *testing.T) {
- withTempStorePath(t)
-
- s := NewActiveTaskStore()
- s.Add(Task{ID: "a", Title: "old"})
- s.Add(Task{ID: "a", Title: "new"}) // same id replaces
-
- loaded := NewActiveTaskStore().Load()
- if len(loaded) != 1 || loaded[0].Title != "new" {
- t.Fatalf("overwrite failed: %+v", loaded)
- }
-}
-
-func TestActiveTaskStore_LoadMissingFile(t *testing.T) {
- withTempStorePath(t) // temp dir, no file written yet
- if got := NewActiveTaskStore().Load(); got != nil {
- t.Errorf("Load on missing file = %+v, want nil", got)
- }
-}
diff --git a/internal/agent/client.go b/internal/agent/client.go
index 897dfa3..9aa3c2a 100644
--- a/internal/agent/client.go
+++ b/internal/agent/client.go
@@ -79,26 +79,6 @@ func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterRe
return &resp, nil
}
-// IssueCert sends a CSR to the web-side ACME broker and returns the signed
-// certificate chain (PEM). The agent's private key never leaves the machine —
-// only the CSR is sent. Used by the per-agent direct-TLS feature.
-func (c *Client) IssueCert(ctx context.Context, csrPEM string) (string, error) {
- req := struct {
- CSRPem string `json:"csrPem"`
- }{CSRPem: csrPEM}
- var resp struct {
- Certificate string `json:"certificate"`
- Error string `json:"error,omitempty"`
- }
- if err := c.doPost(ctx, "/api/internal/agent/issue-cert", req, &resp); err != nil {
- return "", fmt.Errorf("issue cert: %w", err)
- }
- if resp.Certificate == "" {
- return "", fmt.Errorf("issue cert: empty certificate (%s)", resp.Error)
- }
- return resp.Certificate, nil
-}
-
// Deregister notifies the server that the agent is shutting down.
func (c *Client) Deregister(ctx context.Context, agentID string) error {
req := struct {
@@ -111,110 +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, health *SessionHealth) error {
- req := struct {
- SessionID string `json:"sessionId"`
- Health *SessionHealth `json:"health,omitempty"`
- }{SessionID: sessionID, Health: health}
- 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
-}
-
-// ReportSessionError is the failure-path counterpart of MarkSessionReady: it
-// tells the web a streaming session can NOT start (file gone, path rejected,
-// ffmpeg missing, spawn failure…). The web marks the session failed, pushes an
-// SSE "failed" event so the player stops probing a playlist that will never
-// exist, and self-heals stale library state on code "file_missing".
-//
-// code is one of the stable machine codes the web understands:
-// "file_missing" | "path_rejected" | "no_video_file" | "ffmpeg_unavailable" |
-// "start_failed". message is free-form detail for diagnostics.
-//
-// Best-effort like MarkSessionReady: on older web deployments without the
-// endpoint this 404s — the caller logs and the player falls back to its
-// probe-deadline behaviour, exactly as before this channel existed.
-func (c *Client) ReportSessionError(ctx context.Context, sessionID, code, message string) error {
- req := struct {
- SessionID string `json:"sessionId"`
- Code string `json:"code"`
- Message string `json:"message,omitempty"`
- }{SessionID: sessionID, Code: code, Message: message}
- var resp StatusResponse
- if err := c.doPost(ctx, "/api/internal/agent/session-error", req, &resp); err != nil {
- return fmt.Errorf("report session error: %w", err)
- }
- return nil
-}
-
-// SessionHealth is an OPTIONAL live-transcode health snapshot attached to a
-// session-ready report (F3). A nil *SessionHealth means the agent has no
-// telemetry to share (cache hit, direct-play, or progress not yet stable) and
-// the web side keeps its stall-shape heuristic. Old web replicas ignore the
-// extra field; old agents simply never send it.
-type SessionHealth struct {
- // "ok" (≥ realtime) | "marginal" (keeps up barely) | "struggling" (can't).
- Health string `json:"health"`
- // ffmpeg speed= EWMA: 1.0 = exactly realtime, < 1.0 = slower than playback.
- RealtimeRatio float64 `json:"realtimeRatio"`
- // "realtime" | "transcode" (encoder is the wall) | "input_bound" (source
- // read) | "copy" (HLS-copy session: no encode — always realtime; the
- // heartbeat exists so the web can tell "copy session" from "old agent
- // with no telemetry", which both used to read as a null health).
- Reason string `json:"reason"`
-}
-
-// RefreshStreamURL re-resolves a fresh debrid direct URL for a live streaming
-// session (hueco #2 / 2c). Called by the daemon when a debrid source expires
-// mid-stream (the link is time-limited; the content is still cached). Returns
-// the new URL on success; an error (incl. 409/410) means refresh isn't
-// possible and the caller should stop trying.
-func (c *Client) RefreshStreamURL(ctx context.Context, sessionID string) (string, error) {
- req := struct {
- SessionID string `json:"sessionId"`
- }{SessionID: sessionID}
- var resp struct {
- DirectURL string `json:"directUrl"`
- }
- if err := c.doPost(ctx, "/api/internal/agent/stream-url", req, &resp); err != nil {
- return "", fmt.Errorf("refresh stream url: %w", err)
- }
- if resp.DirectURL == "" {
- return "", fmt.Errorf("refresh stream url: empty url in response")
- }
- return resp.DirectURL, 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
@@ -512,14 +388,3 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error {
return nil
}
-
-// SubmitSkipSegments uploads detected intro/credits segments after a library
-// scan. Must run AFTER SyncLibrary — the server resolves file paths against
-// the freshly-synced library_item rows.
-func (c *Client) SubmitSkipSegments(ctx context.Context, req SkipSegmentsRequest) (*SkipSegmentsResponse, error) {
- var resp SkipSegmentsResponse
- if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/skip-segments", req, &resp); err != nil {
- return nil, fmt.Errorf("skip segments: %w", err)
- }
- return &resp, nil
-}
diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go
index ae7395b..e5e4c60 100644
--- a/internal/agent/daemon.go
+++ b/internal/agent/daemon.go
@@ -22,25 +22,12 @@ type DaemonConfig struct {
Version string
DownloadDir string
StreamPort int // port for the HTTP stream server
- HTTPSStreamPort int // TLS stream listener port (per-agent direct-TLS); 0 when off
- AgentHash string // stable high-entropy hash for *..agent.unarr.app
- StreamSecret string // hex HMAC key for stream tokens (reported so the web can mint HLS tokens)
LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
CanDelete bool // library.allow_delete is enabled
ScanPaths []string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
- // Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon
- // start. Surfaced in the web "Diagnose transcoder" modal — lets a user
- // see which encoders the ffmpeg binary supports and which devices the
- // host exposes without running `unarr probe-hwaccel`.
- FFmpegVersion string // first line of `ffmpeg -version`
- FFmpegPath string // resolved binary path
- HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders`
- HWDevices []string // device files + driver bins detected at probe time
- AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
- Downlink string // realtime downlink transport: "auto" (SSE+long-poll fallback) | "sse" | "poll"
}
// Daemon manages agent registration and the sync loop.
@@ -56,16 +43,6 @@ type Daemon struct {
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
- // GetActiveStreamCount returns the number of live stream sessions (player +
- // HLS transcode). Wired from cmd. The graceful AUTO-upgrade path defers
- // while this is > 0 so it never cuts a viewer mid-playback; a MANUAL
- // `unarr update` ignores it and applies immediately.
- GetActiveStreamCount func() int
- // OnAgentKeyMinted fires when a register reply carries a freshly-minted
- // per-machine key (the daemon registered with a general/legacy key). cmd
- // persists it so the next start authenticates with the bound agent key —
- // migrating legacy agents and stopping the per-restart re-mint.
- OnAgentKeyMinted func(newKey string)
// State
User UserInfo
@@ -73,8 +50,6 @@ type Daemon struct {
Info AgentInfo
State DaemonState
lastNotifiedVersion string
- // upgradeDeferring guards a single defer-until-idle waiter for auto-upgrade.
- upgradeDeferring atomic.Bool
// Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
// into DaemonState on every write so external tools (`unarr vpn status`) see it.
@@ -125,13 +100,6 @@ func (d *Daemon) SetFunnelURL(url string) {
WriteState(&d.State)
}
-// UpdateStreamSecret sets the hex HMAC key reported on register so the web can
-// mint HLS stream tokens the agent will accept.
-func (d *Daemon) UpdateStreamSecret(secretHex string) {
- d.cfg.StreamSecret = secretHex
- d.sync.cfg.StreamSecret = secretHex
-}
-
// UpdateStreamPort updates the stream port reported in sync requests.
func (d *Daemon) UpdateStreamPort(port int) {
d.cfg.StreamPort = port
@@ -149,22 +117,14 @@ func (d *Daemon) Register(ctx context.Context) error {
Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir,
StreamPort: d.cfg.StreamPort,
- HTTPSStreamPort: d.cfg.HTTPSStreamPort,
- AgentHash: d.cfg.AgentHash,
- StreamSecret: d.cfg.StreamSecret,
LanIP: d.cfg.LanIP,
TailscaleIP: d.cfg.TailscaleIP,
HWAccel: d.cfg.HWAccel,
MaxTranscodeHeight: d.cfg.MaxTranscodeHeight,
- FFmpegVersion: d.cfg.FFmpegVersion,
- FFmpegPath: d.cfg.FFmpegPath,
- HWEncoders: d.cfg.HWEncoders,
- HWDevices: d.cfg.HWDevices,
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
- IsDocker: RunningInDocker(),
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
@@ -198,12 +158,6 @@ func (d *Daemon) Register(ctx context.Context) error {
return fmt.Errorf("register: %w (after %d retries)", err, maxRetries)
}
- // Registered with a general/legacy key → the server minted a per-machine key.
- // Persist it (cmd wires the callback) so the next start uses the bound key.
- if resp.AgentKey != "" && d.OnAgentKeyMinted != nil {
- d.OnAgentKeyMinted(resp.AgentKey)
- }
-
d.User = resp.User
d.Features = resp.Features
now := time.Now()
@@ -269,12 +223,8 @@ func (d *Daemon) Run(ctx context.Context) error {
}
}
d.sync.OnStreamRequest = func(req StreamRequest) {
- // Off the sync loop: the handler does blocking I/O (os.Stat retries on
- // NFS, then ffprobe in SetFile) — running it inline would stall task
- // dispatch + status reporting for other items. The single-stream model
- // (atomic SetFile swap, last-wins) tolerates concurrent requests.
if d.OnStreamRequested != nil {
- go d.OnStreamRequested(req)
+ d.OnStreamRequested(req)
}
}
d.sync.OnStreamSession = func(sess StreamSession) {
@@ -287,12 +237,8 @@ func (d *Daemon) Run(ctx context.Context) error {
return
}
d.lastNotifiedVersion = version
- if !d.cfg.AutoUpgrade {
- log.Printf("[upgrade] new version available: %s — auto_upgrade=false, run `unarr update` to apply", version)
- return
- }
log.Printf("[upgrade] new version available: %s — applying auto-upgrade", version)
- go d.deferAutoUpgradeUntilIdle(version)
+ go d.applyAutoUpgrade(version)
}
d.sync.OnScan = func() {
log.Printf("Library scan requested by server")
@@ -310,9 +256,6 @@ func (d *Daemon) Run(ctx context.Context) error {
d.sync.GetFunnelURL = func() string {
return d.funnelURL
}
- d.sync.GetAgentStatus = func() string {
- return d.State.Status
- }
d.sync.OnSyncSuccess = func() {
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
@@ -342,75 +285,12 @@ func (d *Daemon) Deregister() {
RemoveState()
}
-// deferAutoUpgradeUntilIdle holds an AUTO-upgrade until the agent is idle (no
-// active stream), then applies it. The user's call: no background update is
-// worth cutting a viewer mid-playback. A MANUAL `unarr update` bypasses this
-// entirely (see cmd/self_update.go) and is the escape hatch for an urgent fix.
-//
-// Runs in its own goroutine. A process-lifetime guard keeps exactly ONE waiter
-// even though the server re-sends the upgrade signal on every sync.
-func (d *Daemon) deferAutoUpgradeUntilIdle(version string) {
- if !d.upgradeDeferring.CompareAndSwap(false, true) {
- return
- }
- defer d.upgradeDeferring.Store(false)
-
- activeStreams := func() int {
- if d.GetActiveStreamCount == nil {
- return 0
- }
- return d.GetActiveStreamCount()
- }
-
- if n := activeStreams(); n > 0 {
- log.Printf("[upgrade] v%s deferred — %d active stream(s); will apply when idle", version, n)
- ticker := time.NewTicker(30 * time.Second)
- defer ticker.Stop()
- for range ticker.C {
- if n := activeStreams(); n == 0 {
- break
- }
- }
- log.Printf("[upgrade] no active streams — applying deferred upgrade to v%s", version)
- }
- d.applyAutoUpgrade(version) // exits the process on success
-}
-
// 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
- }
-
- // Tell the web we're updating so a NEW playback attempt during the brief
- // restart sees "agent updating" instead of a hard session error. One
- // heartbeat carries this before the (blocking) download + os.Exit below.
- d.State.Status = "updating"
- WriteState(&d.State)
- d.TriggerSync()
-
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
OnProgress: func(msg string) {
@@ -422,24 +302,10 @@ func (d *Daemon) applyAutoUpgrade(targetVersion string) {
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",
+ log.Printf("[upgrade] upgraded v%s → v%s; 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)
}
diff --git a/internal/agent/docker.go b/internal/agent/docker.go
deleted file mode 100644
index de5189d..0000000
--- a/internal/agent/docker.go
+++ /dev/null
@@ -1,26 +0,0 @@
-package agent
-
-import "os"
-
-// RunningInDocker reports whether the agent process is running inside a Docker
-// (or compatible OCI) container. The web uses this to swap the in-app "force
-// update" button — which drives the binary self-update path that hard-stops
-// inside a container (see internal/upgrade) — for a copy-paste `docker pull`
-// command instead.
-//
-// Detection order:
-// 1. UNARR_DOCKER env truthy — baked into the official image's Dockerfile, so
-// it also covers podman/containerd running our image (which don't create
-// /.dockerenv).
-// 2. /.dockerenv exists — the standard marker Docker writes into every
-// container, covering images that didn't set the env.
-func RunningInDocker() bool {
- switch os.Getenv("UNARR_DOCKER") {
- case "1", "true", "yes":
- return true
- }
- if _, err := os.Stat("/.dockerenv"); err == nil {
- return true
- }
- return false
-}
diff --git a/internal/agent/downlink_test.go b/internal/agent/downlink_test.go
deleted file mode 100644
index 99d8745..0000000
--- a/internal/agent/downlink_test.go
+++ /dev/null
@@ -1,216 +0,0 @@
-package agent
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-)
-
-func TestDownlinkMode(t *testing.T) {
- cases := map[string]string{
- "": "auto",
- "auto": "auto",
- "AUTO": "auto",
- " sse ": "sse",
- "sse": "sse",
- "poll": "poll",
- "garbage": "auto",
- }
- for in, want := range cases {
- sc, _ := newTestSyncClient("http://127.0.0.1:0")
- sc.cfg.Downlink = in
- if got := sc.downlinkMode(); got != want {
- t.Errorf("downlinkMode(%q) = %q, want %q", in, got, want)
- }
- }
-}
-
-func TestHandleDownlinkEvent_SyncNudge(t *testing.T) {
- sc, _ := newTestSyncClient("http://127.0.0.1:0")
- sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventSync, Data: json.RawMessage(`{"reason":"wake"}`)})
-
- select {
- case <-sc.SyncNow:
- // good — TriggerSync fired
- default:
- t.Error("sync event did not trigger an immediate sync")
- }
-}
-
-func TestHandleDownlinkEvent_TypedControls(t *testing.T) {
- sc, _ := newTestSyncClient("http://127.0.0.1:0")
-
- var gotAction, gotTask string
- var gotDelete bool
- sc.OnControl = func(action, taskID string, deleteFiles bool) {
- gotAction, gotTask, gotDelete = action, taskID, deleteFiles
- }
-
- payload := `{"controls":[{"action":"cancel","taskId":"task-xyz","deleteFiles":true}]}`
- sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventCommand, Data: json.RawMessage(payload)})
-
- if gotAction != "cancel" || gotTask != "task-xyz" || !gotDelete {
- t.Errorf("OnControl got (%q,%q,%v), want (cancel,task-xyz,true)", gotAction, gotTask, gotDelete)
- }
-}
-
-func TestHandleDownlinkEvent_PingIsLivenessOnly(t *testing.T) {
- sc, _ := newTestSyncClient("http://127.0.0.1:0")
- controlCalled := false
- sc.OnControl = func(string, string, bool) { controlCalled = true }
-
- sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventPing})
-
- if controlCalled {
- t.Error("ping must not invoke OnControl")
- }
- select {
- case <-sc.SyncNow:
- t.Error("ping must not trigger a sync")
- default:
- }
-}
-
-func TestHandleDownlinkEvent_BadPayloadNoPanic(t *testing.T) {
- sc, _ := newTestSyncClient("http://127.0.0.1:0")
- sc.OnControl = func(string, string, bool) { t.Error("OnControl must not fire on bad payload") }
- // Should log + return, not panic.
- sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventCommand, Data: json.RawMessage(`{not json`)})
-}
-
-// TestRunEventStreamOnce_Healthy: a server that sends a heartbeat then a sync
-// event, then closes → runEventStreamOnce returns true (healthy) and the sync
-// nudge fired.
-func TestRunEventStreamOnce_Healthy(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- w.WriteHeader(http.StatusOK)
- f, _ := w.(http.Flusher)
- w.Write([]byte(": hb\n\n"))
- if f != nil {
- f.Flush()
- }
- w.Write([]byte("event: sync\ndata: {}\n\n"))
- if f != nil {
- f.Flush()
- }
- // Return → response body closes → stream ends.
- }))
- defer srv.Close()
-
- sc, _ := newTestSyncClient(srv.URL)
- sc.livenessTimeout = 500 * time.Millisecond
-
- healthy := sc.runEventStreamOnce(context.Background())
- if !healthy {
- t.Error("expected healthy=true after receiving frames")
- }
- select {
- case <-sc.SyncNow:
- default:
- t.Error("expected a sync nudge from the sync event")
- }
-}
-
-// TestRunEventStreamOnce_DeadOrBuffered: server connects 200 OK but sends
-// nothing → liveness deadline fires → returns false (so auto mode falls back).
-func TestRunEventStreamOnce_DeadOrBuffered(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- w.WriteHeader(http.StatusOK)
- if f, ok := w.(http.Flusher); ok {
- f.Flush()
- }
- // Send NO frames — simulate a silently-buffering proxy.
- <-r.Context().Done()
- }))
- defer srv.Close()
-
- sc, _ := newTestSyncClient(srv.URL)
- sc.livenessTimeout = 150 * time.Millisecond
-
- start := time.Now()
- healthy := sc.runEventStreamOnce(context.Background())
- if healthy {
- t.Error("expected healthy=false when no frame arrives within liveness deadline")
- }
- if elapsed := time.Since(start); elapsed > 2*time.Second {
- t.Errorf("liveness deadline did not fire promptly (took %s)", elapsed)
- }
-}
-
-// TestRunEventStreamOnce_PreambleThenStall: a partial-buffering proxy that
-// flushes the connect preamble (one heartbeat) then goes silent must be treated
-// as UNHEALTHY (false), so the auto fallback eventually triggers. This is the
-// common buffering mode the zero-frame test doesn't cover.
-func TestRunEventStreamOnce_PreambleThenStall(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- w.WriteHeader(http.StatusOK)
- f, _ := w.(http.Flusher)
- // Flush ONE heartbeat (the preamble) then stall — never send more.
- w.Write([]byte(": connected hb=15000\n\n"))
- if f != nil {
- f.Flush()
- }
- <-r.Context().Done()
- }))
- defer srv.Close()
-
- sc, _ := newTestSyncClient(srv.URL)
- sc.livenessTimeout = 150 * time.Millisecond
-
- if sc.runEventStreamOnce(context.Background()) {
- t.Error("a stream that flushes one ping then stalls must be unhealthy (else fallback never triggers)")
- }
-}
-
-// TestRunEventStreamOnce_ConnectFail: dead server → false, no hang.
-func TestRunEventStreamOnce_ConnectFail(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
- url := srv.URL
- srv.Close() // port now refuses
-
- sc, _ := newTestSyncClient(url)
- sc.livenessTimeout = 500 * time.Millisecond
-
- if sc.runEventStreamOnce(context.Background()) {
- t.Error("expected healthy=false on connect failure")
- }
-}
-
-// TestRunEventStreamOnce_CtxCancel: cancelling ctx returns promptly.
-func TestRunEventStreamOnce_CtxCancel(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- w.WriteHeader(http.StatusOK)
- if f, ok := w.(http.Flusher); ok {
- f.Flush()
- }
- <-r.Context().Done()
- }))
- defer srv.Close()
-
- sc, _ := newTestSyncClient(srv.URL)
- sc.livenessTimeout = 10 * time.Second
-
- ctx, cancel := context.WithCancel(context.Background())
- go func() {
- time.Sleep(100 * time.Millisecond)
- cancel()
- }()
-
- done := make(chan struct{})
- go func() {
- sc.runEventStreamOnce(ctx)
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("runEventStreamOnce did not return after ctx cancel")
- }
-}
diff --git a/internal/agent/events_client.go b/internal/agent/events_client.go
deleted file mode 100644
index a12301b..0000000
--- a/internal/agent/events_client.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package agent
-
-import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "strings"
-)
-
-// DownlinkEvent is one parsed Server-Sent Event from the agent events stream
-// (GET /api/internal/agent/events). Event is the SSE "event:" name; Data is the
-// raw "data:" payload (nil for heartbeat pings).
-type DownlinkEvent struct {
- Event string
- Data json.RawMessage
-}
-
-// CommandEvent is the payload of an "command" downlink event — typed control
-// actions the server pushes for instant application (cancel/pause). Mirrors the
-// `controls` field of /agent/sync so the same OnControl callback handles both.
-type CommandEvent struct {
- Controls []ControlAction `json:"controls"`
-}
-
-// Downlink event names. Heartbeat pings surface as a distinct event so the
-// consumer can reset its liveness deadline without acting on them.
-const (
- DownlinkEventPing = "ping" // SSE comment line (`: hb`) — liveness only
- DownlinkEventSync = "sync" // nudge: run a full /agent/sync
- DownlinkEventCommand = "command" // typed control actions
-)
-
-// Bounds on the SSE reader, identical in spirit to the retired WebRTC signal
-// reader: a hostile or buggy server must not be able to grow daemon memory by
-// streaming one unbounded line or unbounded `data:` continuation lines.
-const (
- eventsSSEMaxLineBytes = 256 * 1024
- eventsSSEMaxEventBytes = 1024 * 1024
-)
-
-// EventStream wraps an open SSE downlink connection. Read from Events() until
-// the channel closes (server recycle, network drop, or ctx cancel), then call
-// Close() and reopen if you want to keep listening. Always defer Close().
-type EventStream struct {
- resp *http.Response
- cancel context.CancelFunc
- events chan DownlinkEvent
- errs chan error
- done chan struct{}
-}
-
-// Events streams server-pushed downlink events. Heartbeat comments surface as
-// DownlinkEvent{Event: DownlinkEventPing}. The channel closes when the
-// connection ends.
-func (s *EventStream) Events() <-chan DownlinkEvent { return s.events }
-
-// Err returns the terminating error (if any) once Events() has closed.
-func (s *EventStream) Err() error {
- select {
- case err := <-s.errs:
- return err
- default:
- return nil
- }
-}
-
-// Close cancels the request and waits for the reader goroutine to drain.
-// Safe to call more than once.
-func (s *EventStream) Close() error {
- if s.cancel != nil {
- s.cancel()
- }
- if s.resp != nil {
- s.resp.Body.Close()
- }
- <-s.done
- return nil
-}
-
-// OpenEventStream opens a long-lived SSE connection to the agent events
-// downlink. Routed through MirrorPool failover for the INITIAL connect only
-// (a mid-stream drop is surfaced as a closed channel, not retried here — the
-// caller reopens). Caller MUST Close() (or cancel ctx) to free resources.
-func (c *Client) OpenEventStream(ctx context.Context) (*EventStream, error) {
- streamCtx, cancel := context.WithCancel(ctx)
-
- var resp *http.Response
- err := c.withMirrorFailover(func(base string) error {
- req, reqErr := http.NewRequestWithContext(streamCtx, http.MethodGet, base+"/api/internal/agent/events", nil)
- if reqErr != nil {
- return fmt.Errorf("create events request: %w", reqErr)
- }
- c.setHeaders(req)
- req.Header.Set("Accept", "text/event-stream")
- req.Header.Set("Cache-Control", "no-cache")
-
- // No-timeout client: the connection is intentionally long-lived; ctx
- // controls cancellation (same as the wake long-poll).
- r, doErr := c.wakeClient.Do(req)
- if doErr != nil {
- return fmt.Errorf("events request failed: %w", doErr)
- }
- if r.StatusCode != http.StatusOK {
- body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<10))
- r.Body.Close()
- return &HTTPError{StatusCode: r.StatusCode, Message: strings.TrimSpace(string(body))}
- }
- resp = r
- return nil
- })
- if err != nil {
- cancel()
- return nil, err
- }
-
- stream := &EventStream{
- resp: resp,
- cancel: cancel,
- events: make(chan DownlinkEvent, 8),
- errs: make(chan error, 1),
- done: make(chan struct{}),
- }
- go stream.read()
- return stream, nil
-}
-
-func (s *EventStream) read() {
- defer close(s.done)
- defer close(s.events)
-
- scanner := bufio.NewScanner(s.resp.Body)
- scanner.Buffer(make([]byte, 16*1024), eventsSSEMaxLineBytes)
-
- ctx := s.resp.Request.Context()
- var dataBuf bytes.Buffer
- var eventName string
-
- emit := func(ev DownlinkEvent) bool {
- select {
- case s.events <- ev:
- return true
- case <-ctx.Done():
- return false
- }
- }
-
- for scanner.Scan() {
- line := strings.TrimRight(scanner.Text(), "\r")
-
- if line == "" {
- // Blank line ends an event — dispatch if we accumulated data.
- if dataBuf.Len() > 0 {
- name := eventName
- if name == "" {
- name = "message"
- }
- data := make([]byte, dataBuf.Len())
- copy(data, dataBuf.Bytes())
- if !emit(DownlinkEvent{Event: name, Data: json.RawMessage(data)}) {
- return
- }
- }
- dataBuf.Reset()
- eventName = ""
- continue
- }
-
- if strings.HasPrefix(line, ":") {
- // SSE comment / heartbeat — surface as a ping so the consumer resets
- // its liveness deadline (and can tell a live stream from a silently
- // buffered one that never delivers anything).
- if !emit(DownlinkEvent{Event: DownlinkEventPing}) {
- return
- }
- continue
- }
- if strings.HasPrefix(line, "event:") {
- eventName = strings.TrimSpace(line[len("event:"):])
- continue
- }
- if strings.HasPrefix(line, "data:") {
- payload := strings.TrimSpace(line[len("data:"):])
- if dataBuf.Len()+len(payload)+1 > eventsSSEMaxEventBytes {
- select {
- case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", eventsSSEMaxEventBytes):
- default:
- }
- return
- }
- if dataBuf.Len() > 0 {
- dataBuf.WriteByte('\n')
- }
- dataBuf.WriteString(payload)
- continue
- }
- // id:, retry:, unknown fields — ignored.
- }
- if err := scanner.Err(); err != nil {
- select {
- case s.errs <- err:
- default:
- }
- }
-}
diff --git a/internal/agent/events_client_test.go b/internal/agent/events_client_test.go
deleted file mode 100644
index 3ef079f..0000000
--- a/internal/agent/events_client_test.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package agent
-
-import (
- "context"
- "errors"
- "fmt"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-)
-
-// sseServer returns an httptest server that writes the given raw SSE body and
-// flushes, then holds the connection until the request context is cancelled (so
-// the client drives the close, like the real long-lived endpoint).
-func sseServer(t *testing.T, body string) *httptest.Server {
- t.Helper()
- return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
- w.WriteHeader(http.StatusOK)
- if _, err := w.Write([]byte(body)); err != nil {
- return
- }
- if f, ok := w.(http.Flusher); ok {
- f.Flush()
- }
- <-r.Context().Done()
- }))
-}
-
-func TestOpenEventStream_ParsesTypedEvents(t *testing.T) {
- body := "retry: 2000\n\n" +
- ": connected hb=15000\n\n" +
- "event: sync\ndata: {\"reason\":\"wake\"}\n\n" +
- "event: command\ndata: {\"controls\":[{\"action\":\"cancel\",\"taskId\":\"t1\"}]}\n\n"
- srv := sseServer(t, body)
- defer srv.Close()
-
- _, client := newTestSyncClient(srv.URL)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- stream, err := client.OpenEventStream(ctx)
- if err != nil {
- t.Fatalf("OpenEventStream: %v", err)
- }
- defer stream.Close()
-
- var got []DownlinkEvent
- timeout := time.After(2 * time.Second)
- for len(got) < 3 {
- select {
- case ev, ok := <-stream.Events():
- if !ok {
- t.Fatalf("stream closed early after %d events", len(got))
- }
- got = append(got, ev)
- case <-timeout:
- t.Fatalf("timed out; got %d events: %+v", len(got), got)
- }
- }
-
- // First frame is the heartbeat comment surfaced as a ping.
- if got[0].Event != DownlinkEventPing {
- t.Errorf("event[0] = %q, want ping", got[0].Event)
- }
- if got[1].Event != DownlinkEventSync {
- t.Errorf("event[1] = %q, want sync", got[1].Event)
- }
- if got[2].Event != DownlinkEventCommand {
- t.Errorf("event[2] = %q, want command", got[2].Event)
- }
- if !strings.Contains(string(got[2].Data), "cancel") {
- t.Errorf("command data missing payload: %s", got[2].Data)
- }
-}
-
-func TestOpenEventStream_MultiLineData(t *testing.T) {
- // Two data: lines for one event must join with a newline.
- body := "event: sync\ndata: line1\ndata: line2\n\n"
- srv := sseServer(t, body)
- defer srv.Close()
-
- _, client := newTestSyncClient(srv.URL)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- stream, err := client.OpenEventStream(ctx)
- if err != nil {
- t.Fatalf("OpenEventStream: %v", err)
- }
- defer stream.Close()
-
- select {
- case ev := <-stream.Events():
- if string(ev.Data) != "line1\nline2" {
- t.Errorf("data = %q, want \"line1\\nline2\"", ev.Data)
- }
- case <-time.After(2 * time.Second):
- t.Fatal("timed out waiting for event")
- }
-}
-
-func TestOpenEventStream_RejectsOversizedEvent(t *testing.T) {
- // Many data: continuation lines until past eventsSSEMaxEventBytes → the
- // reader surfaces an error and closes the channel (so the loop reconnects).
- var b strings.Builder
- b.WriteString("event: command\n")
- chunk := "data: " + strings.Repeat("x", 4096) + "\n"
- for b.Len() < eventsSSEMaxEventBytes+8192 {
- b.WriteString(chunk)
- }
- b.WriteString("\n")
- srv := sseServer(t, b.String())
- defer srv.Close()
-
- _, client := newTestSyncClient(srv.URL)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- stream, err := client.OpenEventStream(ctx)
- if err != nil {
- t.Fatalf("OpenEventStream: %v", err)
- }
- defer stream.Close()
-
- // Drain until the channel closes (the oversized event must NOT be emitted).
- timeout := time.After(2 * time.Second)
- for {
- select {
- case ev, ok := <-stream.Events():
- if !ok {
- if stream.Err() == nil {
- t.Error("expected an error after oversized event, got nil")
- }
- return
- }
- if ev.Event == DownlinkEventCommand {
- t.Fatalf("oversized command event must not be dispatched")
- }
- case <-timeout:
- t.Fatal("timed out; channel never closed after oversized event")
- }
- }
-}
-
-func TestOpenEventStream_Non200ReturnsError(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- fmt.Fprint(w, `{"error":"not found"}`)
- }))
- defer srv.Close()
-
- _, client := newTestSyncClient(srv.URL)
- _, err := client.OpenEventStream(context.Background())
- if err == nil {
- t.Fatal("expected error on 404, got nil")
- }
- var httpErr *HTTPError
- if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusNotFound {
- t.Errorf("expected HTTPError 404, got %v", err)
- }
-}
-
-func TestEventStream_CloseCancelsRead(t *testing.T) {
- srv := sseServer(t, ": connected\n\n")
- defer srv.Close()
-
- _, client := newTestSyncClient(srv.URL)
- stream, err := client.OpenEventStream(context.Background())
- if err != nil {
- t.Fatalf("OpenEventStream: %v", err)
- }
-
- // Drain the initial ping.
- select {
- case <-stream.Events():
- case <-time.After(2 * time.Second):
- t.Fatal("no initial ping")
- }
-
- done := make(chan struct{})
- go func() {
- stream.Close()
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("Close() did not return — read goroutine leaked")
- }
-}
diff --git a/internal/agent/mirror_client.go b/internal/agent/mirror_client.go
index 5364be0..683b92b 100644
--- a/internal/agent/mirror_client.go
+++ b/internal/agent/mirror_client.go
@@ -13,7 +13,7 @@ import (
type MirrorEntry struct {
URL string `json:"url"`
Label string `json:"label"`
- Kind string `json:"kind"` // "clearnet" | "tor"
+ Kind string `json:"kind"` // "clearnet" | "tor"
Primary bool `json:"primary"`
}
diff --git a/internal/agent/mirror_transport.go b/internal/agent/mirror_transport.go
deleted file mode 100644
index 906207c..0000000
--- a/internal/agent/mirror_transport.go
+++ /dev/null
@@ -1,88 +0,0 @@
-package agent
-
-import (
- "fmt"
- "net/http"
- "net/url"
-)
-
-// MirrorRoundTripper gives any *http.Client the same mirror failover the agent
-// control-plane Client has: on a transient transport error or a retryable 5xx
-// it rewrites the request to the next mirror in the shared MirrorPool and
-// retries. It exists so the public-API go-client stops diverging from the agent
-// client — both now survive a primary-domain takedown using the SAME pool and
-// the SAME transient-error policy (IsTransient).
-//
-// Requests whose body cannot be replayed (Body != nil && GetBody == nil) are
-// sent once with no failover, so a consumed body is never re-read. Standard
-// library requests built with a *bytes.Reader/strings.Reader (and all GETs) set
-// GetBody, so this only affects exotic streaming bodies the public API doesn't use.
-type MirrorRoundTripper struct {
- pool *MirrorPool
- inner http.RoundTripper
-}
-
-// NewMirrorRoundTripper wraps inner (defaults to http.DefaultTransport) with
-// failover across pool's mirrors.
-func NewMirrorRoundTripper(pool *MirrorPool, inner http.RoundTripper) *MirrorRoundTripper {
- if inner == nil {
- inner = http.DefaultTransport
- }
- return &MirrorRoundTripper{pool: pool, inner: inner}
-}
-
-// RoundTrip points the request at the current mirror and, on a transient
-// failure, rotates the pool and retries against the next one. A non-transient
-// HTTP status (4xx, or a 5xx IsTransient doesn't retry) or a non-replayable body
-// is returned to the caller unchanged.
-func (m *MirrorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- attempts := 1
- if req.Body == nil || req.GetBody != nil { // replayable → may fail over
- if n := m.pool.Len(); n > attempts {
- attempts = n
- }
- }
-
- var lastErr error
- for i := 0; i < attempts; i++ {
- out := req.Clone(req.Context())
- if req.GetBody != nil {
- body, err := req.GetBody()
- if err != nil {
- return nil, fmt.Errorf("mirror transport: rebuild body: %w", err)
- }
- out.Body = body
- }
- if base, err := url.Parse(m.pool.Current()); err == nil && base.Host != "" {
- out.URL.Scheme = base.Scheme
- out.URL.Host = base.Host
- out.Host = base.Host
- }
-
- resp, err := m.inner.RoundTrip(out)
- last := i == attempts-1
- switch {
- case err != nil:
- if last || !IsTransient(err) {
- return nil, err
- }
- lastErr = err
- case resp.StatusCode >= 400 && IsTransient(&HTTPError{StatusCode: resp.StatusCode}):
- if last {
- return resp, nil // surface the real 5xx to the caller
- }
- resp.Body.Close()
- lastErr = fmt.Errorf("mirror %s: HTTP %d", out.URL.Host, resp.StatusCode)
- default:
- return resp, nil // success, or a status we must not retry (4xx/auth)
- }
-
- if _, rotated := m.pool.Rotate(); !rotated {
- break
- }
- }
- if lastErr == nil {
- lastErr = fmt.Errorf("mirror transport: all mirrors failed")
- }
- return nil, lastErr
-}
diff --git a/internal/agent/mirror_transport_test.go b/internal/agent/mirror_transport_test.go
deleted file mode 100644
index 3781b36..0000000
--- a/internal/agent/mirror_transport_test.go
+++ /dev/null
@@ -1,172 +0,0 @@
-package agent
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-)
-
-func TestMirrorRoundTripper_FailoverOn503(t *testing.T) {
- var primaryHits, mirrorHits int
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- primaryHits++
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- defer primary.Close()
- mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- mirrorHits++
- w.WriteHeader(http.StatusOK)
- io.WriteString(w, "ok")
- }))
- defer mirror.Close()
-
- pool := NewMirrorPool(primary.URL, []string{mirror.URL})
- rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
- req, _ := http.NewRequest(http.MethodGet, primary.URL+"/api/v1/search", nil)
-
- resp, err := rt.RoundTrip(req)
- if err != nil {
- t.Fatalf("RoundTrip: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want 200", resp.StatusCode)
- }
- if primaryHits != 1 || mirrorHits != 1 {
- t.Errorf("hits primary=%d mirror=%d, want 1/1", primaryHits, mirrorHits)
- }
-}
-
-func TestMirrorRoundTripper_NoFailoverOn404(t *testing.T) {
- var mirrorHits int
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- }))
- defer primary.Close()
- mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- mirrorHits++
- w.WriteHeader(http.StatusOK)
- }))
- defer mirror.Close()
-
- pool := NewMirrorPool(primary.URL, []string{mirror.URL})
- rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
- req, _ := http.NewRequest(http.MethodGet, primary.URL+"/x", nil)
-
- resp, err := rt.RoundTrip(req)
- if err != nil {
- t.Fatalf("RoundTrip: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusNotFound {
- t.Errorf("status = %d, want 404 (surfaced, not retried)", resp.StatusCode)
- }
- if mirrorHits != 0 {
- t.Errorf("mirror hit %d times — must NOT fail over on 404", mirrorHits)
- }
-}
-
-func TestMirrorRoundTripper_FailoverOnConnRefused(t *testing.T) {
- dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
- deadURL := dead.URL
- dead.Close() // port now refuses connections
-
- mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- }))
- defer mirror.Close()
-
- pool := NewMirrorPool(deadURL, []string{mirror.URL})
- rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
- req, _ := http.NewRequest(http.MethodGet, deadURL+"/x", nil)
-
- resp, err := rt.RoundTrip(req)
- if err != nil {
- t.Fatalf("RoundTrip should have failed over, got: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Errorf("status = %d, want 200 after failover", resp.StatusCode)
- }
-}
-
-func TestMirrorRoundTripper_ReplaysBodyOnFailover(t *testing.T) {
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusBadGateway)
- }))
- defer primary.Close()
- var gotBody string
- mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- b, _ := io.ReadAll(r.Body)
- gotBody = string(b)
- w.WriteHeader(http.StatusOK)
- }))
- defer mirror.Close()
-
- pool := NewMirrorPool(primary.URL, []string{mirror.URL})
- rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
- req, _ := http.NewRequest(http.MethodPost, primary.URL+"/x", strings.NewReader("payload"))
-
- resp, err := rt.RoundTrip(req)
- if err != nil {
- t.Fatalf("RoundTrip: %v", err)
- }
- defer resp.Body.Close()
- if gotBody != "payload" {
- t.Errorf("mirror received body %q, want \"payload\" (body must be replayed on failover)", gotBody)
- }
-}
-
-func TestMirrorRoundTripper_NonReplayableBodyNoFailover(t *testing.T) {
- var primaryHits, mirrorHits int
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- primaryHits++
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- defer primary.Close()
- mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- mirrorHits++
- w.WriteHeader(http.StatusOK)
- }))
- defer mirror.Close()
-
- pool := NewMirrorPool(primary.URL, []string{mirror.URL})
- rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
- // A body with no GetBody can't be replayed → must be sent once, no failover.
- req, _ := http.NewRequest(http.MethodPost, primary.URL+"/x", io.NopCloser(strings.NewReader("payload")))
- req.GetBody = nil
-
- resp, err := rt.RoundTrip(req)
- if err != nil {
- t.Fatalf("RoundTrip: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusServiceUnavailable {
- t.Errorf("status = %d, want 503 (single attempt, no failover)", resp.StatusCode)
- }
- if primaryHits != 1 || mirrorHits != 0 {
- t.Errorf("hits primary=%d mirror=%d, want 1/0 (non-replayable body must not fail over)", primaryHits, mirrorHits)
- }
-}
-
-func TestMirrorRoundTripper_SingleMirrorSurfaces503(t *testing.T) {
- primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusServiceUnavailable)
- }))
- defer primary.Close()
-
- pool := NewMirrorPool(primary.URL, nil)
- rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
- req, _ := http.NewRequest(http.MethodGet, primary.URL+"/x", nil)
-
- resp, err := rt.RoundTrip(req)
- if err != nil {
- t.Fatalf("RoundTrip: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusServiceUnavailable {
- t.Errorf("status = %d, want 503 surfaced (no mirror to fail over to)", resp.StatusCode)
- }
-}
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/sync.go b/internal/agent/sync.go
index b3207ac..ac856a5 100644
--- a/internal/agent/sync.go
+++ b/internal/agent/sync.go
@@ -2,10 +2,8 @@ package agent
import (
"context"
- "encoding/json"
"log"
"runtime"
- "strings"
"sync"
"sync/atomic"
"time"
@@ -17,23 +15,6 @@ const (
// SyncIntervalIdle is the sync interval when nobody is watching.
// Keep this short enough to pick up stream requests quickly without hammering the server.
SyncIntervalIdle = 10 * time.Second
-
- // --- Downlink (server→agent realtime) tuning ---
-
- // downlinkLivenessTimeout is the maximum time to wait for ANY SSE frame
- // (heartbeat comment or event) before declaring the stream dead. The server
- // heartbeats every ~15s; ~2.5× gives slack for jitter while still catching a
- // path that connects 200 OK but silently buffers (delivers nothing until
- // close) — the failure mode that justifies the long-poll fallback.
- downlinkLivenessTimeout = 40 * time.Second
- // sseReconnectDelay is the pause between SSE connection attempts.
- sseReconnectDelay = 2 * time.Second
- // maxSSEFailures is the number of consecutive failed/dead SSE attempts
- // before "auto" mode falls back to the long-poll wake downlink.
- maxSSEFailures = 3
- // downlinkFallbackWindow is how long to ride long-poll before re-probing SSE,
- // so a transient proxy hiccup doesn't pin the agent on polling forever.
- downlinkFallbackWindow = 5 * time.Minute
)
// SyncClient handles bidirectional state synchronization between the CLI and server.
@@ -59,10 +40,6 @@ type SyncClient struct {
// WireGuard tunnel is up, the mode, and the exit server) so the web can track
// which agent holds the single WG slot.
GetVPNState func() (active bool, mode, server string)
- // GetAgentStatus returns the daemon lifecycle state ("running" | "updating"
- // | "shutting_down") so the web can show "agent updating" during an upgrade
- // restart instead of a hard error. Empty → treated as "running".
- GetAgentStatus func() string
// GetFunnelURL returns the CloudFlare Quick Tunnel public hostname if one
// is active, else "". Sent on every sync so the web picks it up live.
GetFunnelURL func() string
@@ -70,50 +47,27 @@ type SyncClient struct {
// It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int
- // OnSubtitleFetch is called when the server requests on-demand subtitle
- // downloads. It should download each (from req.URL, already VTT), write a
- // sidecar next to req.FilePath, and return the IDs successfully fetched plus
- // the ones that failed (so the web can mark them errored).
- OnSubtitleFetch func(reqs []SubtitleFetchRequest) ([]int, []SubtitleFetchError)
-
- // OnRevoked is called when a sync is rejected because this agent's credential
- // was revoked (the user deleted the agent from the dashboard). The daemon
- // wires this to wipe the stored key + stop — it must NOT keep retrying or the
- // server will reject every sync forever.
- OnRevoked func(err error)
-
// SyncNow triggers an immediate sync (e.g., on task completion).
SyncNow chan struct{}
watching atomic.Bool
interval atomic.Int64 // stored as nanoseconds
- // livenessTimeout is the max wait for any SSE frame before the downlink
- // treats the stream as dead/buffered. Defaults to downlinkLivenessTimeout;
- // overridable in tests.
- livenessTimeout time.Duration
-
// pendingDeleteConfirmed holds item IDs to report as deleted in the next sync.
pendingDeleteMu sync.Mutex
pendingDeleteConfirmed []int
// deleteInFlight tracks item IDs currently being processed or awaiting confirmation.
// Prevents the same file from being passed to OnDeleteFiles multiple times.
deleteInFlight map[int]struct{}
-
- // Subtitle-fetch jobs awaiting confirmation + dedup (guarded by pendingDeleteMu).
- pendingSubtitlesFetched []int
- pendingSubtitlesFailed []SubtitleFetchError
- subtitleInFlight map[int]struct{}
}
// NewSyncClient creates a sync client.
func NewSyncClient(client *Client, cfg DaemonConfig, state *LocalState) *SyncClient {
sc := &SyncClient{
- client: client,
- cfg: cfg,
- state: state,
- SyncNow: make(chan struct{}, 1),
- livenessTimeout: downlinkLivenessTimeout,
+ client: client,
+ cfg: cfg,
+ state: state,
+ SyncNow: make(chan struct{}, 1),
}
sc.interval.Store(int64(SyncIntervalIdle))
return sc
@@ -134,9 +88,8 @@ func (sc *SyncClient) TriggerSync() {
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
func (sc *SyncClient) Run(ctx context.Context) error {
- // Start the realtime downlink in background — pushes immediate syncs +
- // typed control commands on demand (SSE-first, long-poll fallback).
- go sc.runDownlink(ctx)
+ // Start wake listener in background — triggers immediate syncs on demand.
+ go sc.runWakeListener(ctx)
// Initial sync immediately
sc.doSync(ctx)
@@ -173,12 +126,6 @@ func (sc *SyncClient) doSync(ctx context.Context) {
resp, err := sc.client.Sync(ctx, req)
if err != nil {
if ctx.Err() == nil {
- // Credential revoked (agent deleted from the dashboard) → stop; don't
- // spam a sync the server will reject forever.
- if IsRevoked(err) && sc.OnRevoked != nil {
- sc.OnRevoked(err)
- return
- }
log.Printf("sync failed: %v", err)
}
return
@@ -192,19 +139,16 @@ func (sc *SyncClient) doSync(ctx context.Context) {
func (sc *SyncClient) buildRequest() SyncRequest {
req := SyncRequest{
- AgentID: sc.cfg.AgentID,
- Name: sc.cfg.AgentName,
- Version: sc.cfg.Version,
- OS: runtime.GOOS,
- Arch: runtime.GOARCH,
- DownloadDir: sc.cfg.DownloadDir,
- StreamPort: sc.cfg.StreamPort,
- HTTPSStreamPort: sc.cfg.HTTPSStreamPort,
- AgentHash: sc.cfg.AgentHash,
- LanIP: sc.cfg.LanIP,
- TailscaleIP: sc.cfg.TailscaleIP,
- CanDelete: sc.cfg.CanDelete,
- IsDocker: RunningInDocker(),
+ AgentID: sc.cfg.AgentID,
+ Name: sc.cfg.AgentName,
+ Version: sc.cfg.Version,
+ OS: runtime.GOOS,
+ Arch: runtime.GOARCH,
+ DownloadDir: sc.cfg.DownloadDir,
+ StreamPort: sc.cfg.StreamPort,
+ LanIP: sc.cfg.LanIP,
+ TailscaleIP: sc.cfg.TailscaleIP,
+ CanDelete: sc.cfg.CanDelete,
}
if sc.GetTaskStates != nil {
req.Tasks = sc.GetTaskStates()
@@ -221,9 +165,6 @@ func (sc *SyncClient) buildRequest() SyncRequest {
if sc.GetVPNState != nil {
req.VPNActive, req.VPNMode, req.VPNServer = sc.GetVPNState()
}
- if sc.GetAgentStatus != nil {
- req.AgentStatus = sc.GetAgentStatus()
- }
if sc.GetFunnelURL != nil {
req.FunnelURL = sc.GetFunnelURL()
}
@@ -238,20 +179,6 @@ func (sc *SyncClient) buildRequest() SyncRequest {
}
sc.pendingDeleteConfirmed = nil
}
- if len(sc.pendingSubtitlesFetched) > 0 {
- req.SubtitlesFetched = sc.pendingSubtitlesFetched
- for _, id := range sc.pendingSubtitlesFetched {
- delete(sc.subtitleInFlight, id)
- }
- sc.pendingSubtitlesFetched = nil
- }
- if len(sc.pendingSubtitlesFailed) > 0 {
- req.SubtitlesFailed = sc.pendingSubtitlesFailed
- for _, f := range sc.pendingSubtitlesFailed {
- delete(sc.subtitleInFlight, f.ID)
- }
- sc.pendingSubtitlesFailed = nil
- }
sc.pendingDeleteMu.Unlock()
return req
}
@@ -323,37 +250,6 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
}(newItems)
}
}
-
- // On-demand subtitle fetches — dedup against in-flight, run off the sync
- // goroutine (network + disk I/O), confirm on the next cycle.
- if len(resp.SubtitleFetches) > 0 && sc.OnSubtitleFetch != nil {
- sc.pendingDeleteMu.Lock()
- if sc.subtitleInFlight == nil {
- sc.subtitleInFlight = make(map[int]struct{})
- }
- var newReqs []SubtitleFetchRequest
- for _, r := range resp.SubtitleFetches {
- if _, inFlight := sc.subtitleInFlight[r.ID]; !inFlight {
- newReqs = append(newReqs, r)
- sc.subtitleInFlight[r.ID] = struct{}{}
- }
- }
- sc.pendingDeleteMu.Unlock()
-
- if len(newReqs) > 0 {
- go func(reqs []SubtitleFetchRequest) {
- done, failed := sc.OnSubtitleFetch(reqs)
- // Both done and failed are reported on the next uplink; buildRequest
- // clears them from subtitleInFlight when it flushes them. A failure
- // becomes status='error' on the web (no silent infinite retry — the
- // user re-requests, which creates a fresh row).
- sc.pendingDeleteMu.Lock()
- sc.pendingSubtitlesFetched = append(sc.pendingSubtitlesFetched, done...)
- sc.pendingSubtitlesFailed = append(sc.pendingSubtitlesFailed, failed...)
- sc.pendingDeleteMu.Unlock()
- }(newReqs)
- }
- }
}
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.
@@ -388,176 +284,6 @@ func (sc *SyncClient) runWakeListener(ctx context.Context) {
}
}
-// runWakeListenerFor runs the long-poll wake listener for up to `dur`, then
-// returns so the caller can re-probe SSE. Used as the auto-mode fallback.
-func (sc *SyncClient) runWakeListenerFor(ctx context.Context, dur time.Duration) {
- childCtx, cancel := context.WithTimeout(ctx, dur)
- defer cancel()
- sc.runWakeListener(childCtx)
-}
-
-// downlinkMode resolves the configured downlink transport:
-// - "auto" (default): SSE-first, fall back to long-poll wake if SSE is
-// unavailable or silently buffered, then periodically re-probe SSE.
-// - "sse": SSE only, no long-poll fallback (testing / known-good networks).
-// - "poll": long-poll wake only (the pre-0.14 behavior).
-func (sc *SyncClient) downlinkMode() string {
- switch strings.ToLower(strings.TrimSpace(sc.cfg.Downlink)) {
- case "poll":
- return "poll"
- case "sse":
- return "sse"
- default:
- return "auto"
- }
-}
-
-// runDownlink is the server→agent realtime loop. It supersedes the bare
-// long-poll wake listener: an SSE connection pushes typed control commands and
-// sync nudges over a single persistent connection, with the long-poll wake as a
-// buffering-tolerant fallback (long-poll survives proxies that buffer the
-// response body and break SSE). Runs until ctx is cancelled.
-func (sc *SyncClient) runDownlink(ctx context.Context) {
- switch sc.downlinkMode() {
- case "poll":
- log.Printf("downlink: long-poll wake (downlink=poll)")
- sc.runWakeListener(ctx)
- case "sse":
- log.Printf("downlink: SSE only (downlink=sse) — no long-poll fallback")
- sc.runSSELoop(ctx, false)
- default:
- sc.runSSELoop(ctx, true)
- }
-}
-
-// runSSELoop maintains the SSE downlink, reconnecting across server recycles
-// and transient drops. When allowFallback is true (auto mode), it switches to
-// the long-poll wake after maxSSEFailures consecutive dead attempts, then
-// re-probes SSE after downlinkFallbackWindow.
-func (sc *SyncClient) runSSELoop(ctx context.Context, allowFallback bool) {
- failures := 0
- for ctx.Err() == nil {
- healthy := sc.runEventStreamOnce(ctx)
- if ctx.Err() != nil {
- return
- }
- if healthy {
- failures = 0
- // A healthy stream that ended is a normal server recycle — reconnect.
- sc.sleep(ctx, sseReconnectDelay)
- continue
- }
-
- failures++
- if allowFallback && failures >= maxSSEFailures {
- log.Printf("downlink: SSE unavailable after %d attempts — falling back to long-poll for %s", failures, downlinkFallbackWindow)
- sc.runWakeListenerFor(ctx, downlinkFallbackWindow)
- failures = 0
- continue
- }
- sc.sleep(ctx, sseReconnectDelay)
- }
-}
-
-// runEventStreamOnce opens one SSE connection and consumes it until it dies or
-// ctx is cancelled. Returns true if the stream was "healthy" — i.e. it
-// delivered at least one frame (event or heartbeat) — and false if it failed to
-// connect or delivered nothing within downlinkLivenessTimeout (dead or silently
-// buffered). The caller uses that signal to decide whether to fall back.
-func (sc *SyncClient) runEventStreamOnce(ctx context.Context) bool {
- streamCtx, cancel := context.WithCancel(ctx)
- defer cancel()
-
- stream, err := sc.client.OpenEventStream(streamCtx)
- if err != nil {
- if ctx.Err() == nil {
- log.Printf("downlink: SSE connect failed: %v", err)
- }
- return false
- }
- defer stream.Close()
-
- healthy := false
- liveness := time.NewTimer(sc.livenessTimeout)
- defer liveness.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return healthy
- case <-liveness.C:
- // No frame within the deadline. The server heartbeats every ~15s, so
- // silence past livenessTimeout (40s) means the path is dead OR
- // silently buffering — INCLUDING a proxy that flushed the connect
- // preamble (one ping) then stalled. Return false REGARDLESS of any
- // earlier frame, so this counts toward the long-poll fallback; a
- // stream that flushes one ping and goes quiet must not be treated as
- // healthy or the fallback never triggers for partial bufferers.
- if ctx.Err() == nil {
- log.Printf("downlink: no SSE frame within %s — dropping (dead or buffered path)", sc.livenessTimeout)
- }
- return false
- case ev, ok := <-stream.Events():
- if !ok {
- if e := stream.Err(); e != nil && ctx.Err() == nil {
- log.Printf("downlink: SSE stream ended: %v", e)
- }
- return healthy
- }
- if !healthy {
- // First frame on this connection — the path flushes, so log once
- // (on a silently-buffered path no frame ever arrives and we never
- // claim connected).
- log.Printf("downlink: SSE connected")
- }
- healthy = true
- if !liveness.Stop() {
- select {
- case <-liveness.C:
- default:
- }
- }
- liveness.Reset(sc.livenessTimeout)
- sc.handleDownlinkEvent(ev)
- }
- }
-}
-
-// handleDownlinkEvent applies one pushed downlink event. Pings are liveness-only;
-// "sync" nudges an immediate full sync; "command" carries typed control actions
-// applied via the same OnControl callback /agent/sync uses (idempotent, so the
-// authoritative sync re-delivering them is harmless).
-func (sc *SyncClient) handleDownlinkEvent(ev DownlinkEvent) {
- switch ev.Event {
- case DownlinkEventPing:
- // Liveness only.
- case DownlinkEventSync:
- sc.TriggerSync()
- case DownlinkEventCommand:
- var cmd CommandEvent
- if err := json.Unmarshal(ev.Data, &cmd); err != nil {
- log.Printf("downlink: bad command payload: %v", err)
- return
- }
- for _, ctrl := range cmd.Controls {
- log.Printf("downlink: control %s on task %s", ctrl.Action, ShortID(ctrl.TaskID))
- if sc.OnControl != nil {
- sc.OnControl(ctrl.Action, ctrl.TaskID, ctrl.DeleteFiles)
- }
- }
- default:
- // Unknown event from a newer server — ignore forward-compatibly.
- }
-}
-
-// sleep blocks for d or until ctx is cancelled.
-func (sc *SyncClient) sleep(ctx context.Context, d time.Duration) {
- select {
- case <-time.After(d):
- case <-ctx.Done():
- }
-}
-
func (sc *SyncClient) adjustInterval(watching bool) {
prev := sc.watching.Load()
sc.watching.Store(watching)
diff --git a/internal/agent/types.go b/internal/agent/types.go
index 1667793..00802bc 100644
--- a/internal/agent/types.go
+++ b/internal/agent/types.go
@@ -1,10 +1,7 @@
package agent
import (
- "errors"
"fmt"
- "net/http"
- "strings"
"time"
)
@@ -19,18 +16,8 @@ type RegisterRequest struct {
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
- // HTTPSStreamPort + AgentHash drive the per-agent direct-TLS feature: the web
- // builds https://..agent.unarr.app:/... once the
- // agent has an issued cert. Zero/empty when the feature is off or pre-cert.
- HTTPSStreamPort int `json:"httpsStreamPort,omitempty"`
- AgentHash string `json:"agentHash,omitempty"`
- LanIP string `json:"lanIp,omitempty"`
- TailscaleIP string `json:"tailscaleIp,omitempty"`
- // StreamSecret is the daemon's per-run HMAC key (hex) for stream tokens. The
- // web mints the HLS path token with it (the agent mints /stream tokens on its
- // own URLs); the agent verifies both. In memory, regenerated each start, so a
- // fresh register after restart re-syncs it.
- StreamSecret string `json:"streamSecret,omitempty"`
+ LanIP string `json:"lanIp,omitempty"`
+ TailscaleIP string `json:"tailscaleIp,omitempty"`
// Transcode capabilities — let the web side suggest a smarter quality
// before the player even starts. HWAccel is the picked backend
// ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is
@@ -39,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.
@@ -59,22 +37,11 @@ type RegisterRequest struct {
// CloudFlare Quick Tunnel hostname when enabled; the web prefers it over
// Tailscale/LAN for in-browser playback because it works on any network.
FunnelURL string `json:"funnelUrl,omitempty"`
- // IsDocker tells the web the agent runs inside a container, so it shows a
- // `docker pull` command instead of the in-app update button (the binary
- // self-update refuses to run in Docker). No omitempty: false (a binary
- // install) is a meaningful state the server must see to keep the button.
- IsDocker bool `json:"isDocker"`
}
// RegisterResponse is returned by the server after registration.
type RegisterResponse struct {
- Success bool `json:"success"`
- // AgentKey is a freshly-minted per-machine API key, present only when the
- // CLI registered with the user's general key (manual-paste bootstrap). The
- // CLI must persist it and authenticate with it from then on, discarding the
- // general key. Empty in the browser-authorize path (the token already IS the
- // agent key) and on every later register.
- AgentKey string `json:"agentKey,omitempty"`
+ Success bool `json:"success"`
User UserInfo `json:"user"`
Features FeatureFlags `json:"features"`
}
@@ -153,11 +120,6 @@ type StatusUpdate struct {
StreamURL string `json:"streamUrl,omitempty"`
StreamReady bool `json:"streamReady,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
- // StreamError reports a failed /stream attempt (path rejected, transient
- // FS error, etc.) WITHOUT marking the download itself failed — the web
- // clears streamRequested + surfaces this so the player fails fast with the
- // real reason instead of a 20s "agent didn't respond" timeout.
- StreamError string `json:"streamError,omitempty"`
// mode=seed_file: agent computes the info_hash from the local file
// and reports it back so the web player can target /stream/.
InfoHash string `json:"infoHash,omitempty"`
@@ -207,32 +169,6 @@ func (e *HTTPError) Error() string {
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}
-// IsRevoked reports whether an error is an EXPLICIT server revocation signal —
-// the user deleted this agent from the dashboard. The server sends 410
-// agent_revoked (the registration is tombstoned OR the per-machine key was
-// revoked — the auth layer maps a revoked agent key to 410, not 401) or 403
-// agent_key_mismatch (the key belongs to another machine). On these the daemon
-// wipes its credential and requires a fresh `unarr login`.
-//
-// A BARE 401 is deliberately NOT treated as revoked: it's ambiguous (a deploy
-// blip, a load-balancer hiccup, a transient auth error) and must never wipe a
-// working agent's credential. The retry/log paths handle a transient 401; a
-// genuine revocation always arrives as 410.
-func IsRevoked(err error) bool {
- var he *HTTPError
- if !errors.As(err, &he) {
- return false
- }
- if he.StatusCode == http.StatusGone {
- return true
- }
- if he.StatusCode == http.StatusForbidden &&
- strings.Contains(he.Message, "agent_key_mismatch") {
- return true
- }
- return false
-}
-
// AgentInfo holds metadata about the running agent for display.
type AgentInfo struct {
ID string
@@ -363,20 +299,8 @@ type DebridAccount struct {
type LibrarySyncRequest struct {
Items []LibrarySyncItem `json:"items"`
ScanPath string `json:"scanPath"`
- AgentID string `json:"agentId,omitempty"` // lets the server scope stale-cleanup per agent
IsLastBatch bool `json:"isLastBatch"`
SyncStartedAt string `json:"syncStartedAt,omitempty"` // ISO-8601; same for all batches in a session
- // ScanRoots lists EVERY root this sync session covered (a session spans all
- // roots since 1.0.9 — one syncStartedAt, one isLastBatch). The server scopes
- // stale-row cleanup of a partial session to these prefixes. Older servers
- // ignore the field and fall back to ScanPath.
- ScanRoots []string `json:"scanRoots,omitempty"`
- // FullCycle marks a session that covered every root the agent scans
- // (daemon auto-scan, `unarr scan` without args). The server may then reap
- // unseen rows REGARDLESS of path prefix — old-base-path ghost rows
- // included. Must stay false for a manual subtree scan or when any root's
- // scan failed, or the cleanup would reap rows the session never visited.
- FullCycle bool `json:"fullCycle,omitempty"`
}
// LibrarySyncItem is a single scanned media file with ffprobe metadata.
@@ -400,17 +324,6 @@ type LibrarySyncItem struct {
AudioTracks any `json:"audioTracks,omitempty"`
SubtitleTracks any `json:"subtitleTracks,omitempty"`
VideoInfo any `json:"videoInfo,omitempty"`
- // Integrity flags a damaged / incompletely-downloaded file ("damaged" or
- // empty). IntegrityReason is a stable code (ebml_corrupt, moov_missing,
- // no_duration, …) the web maps to a localized "re-download" message.
- Integrity string `json:"integrity,omitempty"`
- IntegrityReason string `json:"integrityReason,omitempty"`
- // Path resilience: a stable content identity + the file's location relative
- // to its library root, so the server can move a row in place on a rename /
- // base-path change instead of duplicating it.
- Fingerprint string `json:"fingerprint,omitempty"`
- RelPath string `json:"relPath,omitempty"`
- LibraryRootKey string `json:"libraryRootKey,omitempty"`
}
// LibrarySyncResponse is returned after syncing library items.
@@ -436,19 +349,12 @@ type SyncRequest struct {
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
- HTTPSStreamPort int `json:"httpsStreamPort,omitempty"`
- AgentHash string `json:"agentHash,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
FreeSlots int `json:"freeSlots"`
Tasks []TaskState `json:"tasks"`
CanDelete bool `json:"canDelete"` // library.allow_delete is enabled
DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk
- // Subtitle-fetch job IDs the agent completed (sidecar written to disk).
- SubtitlesFetched []int `json:"subtitlesFetched,omitempty"`
- // Subtitle-fetch jobs that permanently failed (download/write error) — the web
- // marks them errored so the UI fails fast instead of waiting for a timeout.
- SubtitlesFailed []SubtitleFetchError `json:"subtitlesFailed,omitempty"`
// Live managed-VPN split-tunnel state, sent every sync so the web sees the
// WireGuard slot owner update in near-realtime (vs. register, once at startup).
// VPNActive has no omitempty: false (tunnel down) must reach the server so it
@@ -458,13 +364,6 @@ type SyncRequest struct {
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled, else empty.
FunnelURL string `json:"funnelUrl,omitempty"`
- // IsDocker — see RegisterRequest.IsDocker. Sent every sync so the web keeps
- // the flag fresh even if the agent migrated binary↔docker between restarts.
- IsDocker bool `json:"isDocker"`
- // AgentStatus — daemon lifecycle state ("running" | "updating" |
- // "shutting_down"). Lets the web show "agent updating" during an upgrade
- // restart instead of a hard session error. Empty (older agents) → "running".
- AgentStatus string `json:"agentStatus,omitempty"`
}
// ControlAction represents a server-side control signal for a task.
@@ -485,91 +384,30 @@ type LibraryDeleteRequest struct {
// HLS registry; source bytes come from FilePath (or, when only InfoHash is
// set, from a download_task on disk).
type StreamSession struct {
- SessionID string `json:"sessionId"`
- FilePath string `json:"filePath,omitempty"`
- InfoHash string `json:"infoHash,omitempty"`
- TaskID string `json:"taskId,omitempty"`
- FileName string `json:"fileName,omitempty"`
- FileSize int64 `json:"fileSize,omitempty"`
+ SessionID string `json:"sessionId"`
+ FilePath string `json:"filePath,omitempty"`
+ InfoHash string `json:"infoHash,omitempty"`
+ TaskID string `json:"taskId,omitempty"`
+ FileName string `json:"fileName,omitempty"`
+ FileSize int64 `json:"fileSize,omitempty"`
// Quality target the daemon should aim for when transcoding. One of
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (defer to config).
Quality string `json:"quality,omitempty"`
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
// "use the default/first track".
AudioIndex int `json:"audioIndex,omitempty"`
- // BurnSubtitleIndex, when set, is the 0-based subtitle stream index
- // (-map 0:s:N) of a BITMAP subtitle (PGS/DVB) to burn into the video. Text
- // subtitles are served as separate WebVTT tracks and never burned. A pointer
- // (not int) so absent/null = "no burn": the zero value 0 is a valid track
- // index, so an int sentinel would silently burn track 0 when the field is
- // omitted. Forces a full video re-encode (the overlay can't ride a copy
- // path), so the web only sends it when the user picks a bitmap sub.
- BurnSubtitleIndex *int `json:"burnSubtitleIndex,omitempty"`
- // StartSec is the playback position (seconds) the viewer opens at — the
- // saved resume point, or the current position on a quality/audio switch.
- // HLS sessions spawn the FIRST ffmpeg already seeked there instead of
- // encoding from segment 0 and immediately seek-restarting (double spawn,
- // slow resume). 0/omitted = start at the beginning. Older daemons simply
- // don't decode the field and keep the old start-at-0 behaviour.
- StartSec float64 `json:"startSec,omitempty"`
- // Prewarm marks a background cache-fill session (next-episode prewarm,
- // hover prewarm): the daemon must encode it WITHOUT displacing the
- // viewer's live session — it waits until the active encode finishes and
- // registers alongside instead of evicting (Register kills every other
- // session; a prewarm claimed mid-playback used to kill the stream the
- // user was watching). False/omitted = a real viewer session.
- Prewarm bool `json:"prewarm,omitempty"`
- // PlayMethod is how the daemon should serve this session:
- // "" — default (HLS transcode); also what legacy servers send.
- // "direct" — the source is already browser-native (the web decided this
- // from library scan metadata + an agent-version gate). Serve
- // the raw file over /stream (HTTP Range, no ffmpeg) instead of
- // transcoding to HLS. See hueco #3 phase 3a in the roadmap.
- PlayMethod string `json:"playMethod,omitempty"`
- // VideoCopy (playMethod "hls" only): serve via HLS-copy — ffmpeg -c:v copy
- // into fMP4 segments, audio to AAC when needed. The robust replacement for
- // the progressive-remux path: same near-zero CPU (video never re-encoded,
- // works on a GPU-less NAS), but in the segmented transport every player
- // handles. Set by webs that know this agent supports it (gate: HLS_COPY_MIN_VERSION web-side).
- VideoCopy bool `json:"videoCopy,omitempty"`
- // DirectURL, when set, is an HTTPS link to the media resolved server-side
- // from the user's debrid account (hueco #2 / 2a). The source has no local
- // file: the daemon streams /stream from this URL via ranged GETs
- // (debridFileProvider) instead of from disk/torrent. Carries the "play
- // instantáneo cache-fast" promise — the web only sets it when the hash is
- // confirmed debrid-cached and the container is browser-native (mp4/m4v),
- // and gates it on an agent-version floor so older daemons never receive a
- // field they can't serve. Takes priority over FilePath when present.
- DirectURL string `json:"directUrl,omitempty"`
}
// SyncResponse is returned by the server with all pending actions for the CLI.
type SyncResponse struct {
- NewTasks []Task `json:"newTasks,omitempty"`
- Controls []ControlAction `json:"controls,omitempty"`
- StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
- StreamSessions []StreamSession `json:"streamSessions,omitempty"`
- Watching bool `json:"watching"`
- Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
- Scan bool `json:"scan,omitempty"`
- FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
- SubtitleFetches []SubtitleFetchRequest `json:"subtitleFetches,omitempty"`
-}
-
-// SubtitleFetchRequest is a server-side request to download a subtitle (from our
-// proxy URL, already charset-fixed + VTT) and save it as a sidecar next to a
-// media file. URL is the absolute /api/internal/subtitles/proxy URL.
-type SubtitleFetchRequest struct {
- ID int `json:"id"`
- FilePath string `json:"filePath"`
- Lang string `json:"lang"`
- URL string `json:"url"`
-}
-
-// SubtitleFetchError reports a permanently-failed subtitle fetch back to the web.
-type SubtitleFetchError struct {
- ID int `json:"id"`
- Error string `json:"error"`
+ NewTasks []Task `json:"newTasks,omitempty"`
+ Controls []ControlAction `json:"controls,omitempty"`
+ StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
+ StreamSessions []StreamSession `json:"streamSessions,omitempty"`
+ Watching bool `json:"watching"`
+ Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
+ Scan bool `json:"scan,omitempty"`
+ FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
}
// ---------------------------------------------------------------------------
@@ -592,38 +430,3 @@ type WatchProgressUpdate struct {
type WatchProgressResponse struct {
Success bool `json:"success"`
}
-
-// ---------------------------------------------------------------------------
-// Skip-segment types (intro/credits detection — see library/skipdetect.go)
-// ---------------------------------------------------------------------------
-
-// SkipSegmentRange is one detected skippable range inside a media file.
-type SkipSegmentRange struct {
- Category string `json:"category"` // "intro" | "credits"
- StartSec float64 `json:"startSec"`
- EndSec float64 `json:"endSec"`
-}
-
-// SkipSegmentItem carries the detected segments of one library file. The
-// server resolves FilePath against the user's library_item rows (synced just
-// before) to attach the segments to a content identity.
-type SkipSegmentItem struct {
- FilePath string `json:"filePath"`
- Title string `json:"title,omitempty"`
- Season int `json:"season,omitempty"`
- Episode int `json:"episode,omitempty"`
- DurationSec float64 `json:"durationSec"`
- Segments []SkipSegmentRange `json:"segments"`
-}
-
-// SkipSegmentsRequest submits detected skip segments after a library scan.
-type SkipSegmentsRequest struct {
- AgentID string `json:"agentId,omitempty"`
- Items []SkipSegmentItem `json:"items"`
-}
-
-// SkipSegmentsResponse reports how many segments the server stored.
-type SkipSegmentsResponse struct {
- Stored int `json:"stored"`
- Unmatched int `json:"unmatched"`
-}
diff --git a/internal/cmd/auth_browser.go b/internal/cmd/auth_browser.go
index 68256df..186813a 100644
--- a/internal/cmd/auth_browser.go
+++ b/internal/cmd/auth_browser.go
@@ -24,7 +24,7 @@ const browserAuthTimeout = 60 * time.Second
// 3. User logs in and clicks "Authorize" on the web page
// 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state}
// 5. CLI validates state, extracts token, closes server
-func browserAuth(apiURL, agentID string) (string, error) {
+func browserAuth(apiURL string) (string, error) {
// Validate apiURL is a well-formed HTTP(S) URL
parsed, err := url.Parse(apiURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
@@ -96,12 +96,8 @@ func browserAuth(apiURL, agentID string) (string, error) {
}
}()
- // Open browser. Forward the agentId so the server mints a per-machine key
- // bound to it (omitted → server falls back to the legacy general key).
+ // Open browser
authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port)
- if agentID != "" {
- authURL += "&agentId=" + url.QueryEscape(agentID)
- }
openBrowser(authURL)
// Listen for Enter key to skip to manual fallback
diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go
index ecd81dc..334d815 100644
--- a/internal/cmd/config_menu.go
+++ b/internal/cmd/config_menu.go
@@ -322,14 +322,6 @@ func configLibrary(cfg *config.Config) error {
Title("Allow file deletion from web UI?").
Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered.").
Value(&cfg.Library.AllowDelete),
- huh.NewConfirm().
- Title("Cache subtitles during scan?").
- Description("Extract embedded text subtitles to WebVTT once during the scan and store them\nbeside the media (hidden .unarr dir) so playback subtitles are instant — and huge\nremuxes don't time out extracting on demand. Local only; nothing is uploaded.").
- Value(&cfg.Library.CacheSubtitles),
- huh.NewConfirm().
- Title("Cache thumbnails during scan?").
- Description("Pre-extract a few preview frames per file (hidden .unarr dir) so the file panel\nand seekbar previews load instantly. Small optimized JPEGs; local only.").
- Value(&cfg.Library.CacheThumbnails),
),
).Run()
}
diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go
index b45ae7f..6c00e95 100644
--- a/internal/cmd/daemon.go
+++ b/internal/cmd/daemon.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log"
- "math"
"os"
"os/signal"
"path/filepath"
@@ -14,9 +13,7 @@ import (
"time"
"github.com/fatih/color"
- "github.com/gofrs/flock"
"github.com/spf13/cobra"
- "github.com/torrentclaw/unarr/internal/acme"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
@@ -120,31 +117,6 @@ func runDaemonStart() error {
return fmt.Errorf("no download directory — run 'unarr init' first")
}
- // Single-instance lock: refuse to start if another daemon already holds
- // this config dir. Two daemons sharing one config.toml race over the same
- // agentId / agentHash / streamSecret and corrupt each other's sync state.
- // flock is advisory + kernel-released on process death (even SIGKILL), so
- // there's no stale-lock problem. A separate UNARR_CONFIG_DIR gets its own
- // lock path and runs concurrently (this is how the dev agent coexists).
- lockDir := config.Dir()
- if err := os.MkdirAll(lockDir, 0o755); err != nil {
- return fmt.Errorf("create config dir: %w", err)
- }
- instanceLock := flock.New(config.LockPath())
- locked, err := instanceLock.TryLock()
- if err != nil {
- return fmt.Errorf("acquire instance lock %s: %w", config.LockPath(), err)
- }
- if !locked {
- return fmt.Errorf("another unarr daemon is already running for this config (%s).\n"+
- "Stop it with 'unarr stop', or use a separate UNARR_CONFIG_DIR to run a second agent", lockDir)
- }
- defer func() {
- if err := instanceLock.Unlock(); err != nil {
- log.Printf("[lock] release %s: %v", config.LockPath(), err)
- }
- }()
-
// Validate configured paths are safe
if err := cfg.ValidatePaths(); err != nil {
return fmt.Errorf("unsafe configuration: %w", err)
@@ -155,23 +127,6 @@ func runDaemonStart() error {
return fmt.Errorf("create download dir: %w", err)
}
- // Per-agent direct-TLS: ensure a stable high-entropy hash exists, generated
- // + persisted once. Distinct from the (enumerable) agent UUID; the cert
- // broker issues *..agent.unarr.app for it.
- if cfg.Download.HTTPSStreamPort > 0 && cfg.Agent.Hash == "" {
- if h, err := acme.GenerateHash(); err != nil {
- log.Printf("[acme] could not generate agent hash (%v) — direct-TLS disabled", err)
- } else {
- cfg.Agent.Hash = h
- if err := config.Save(cfg, config.FilePath()); err != nil {
- log.Printf("[acme] could not persist agent hash (%v) — direct-TLS disabled until persisted", err)
- cfg.Agent.Hash = ""
- } else {
- log.Printf("[acme] generated agent hash %s", h)
- }
- }
- }
-
// Clean up stale resume files (>7 days old)
resumeDir := filepath.Join(config.DataDir(), "resume")
if removed := download.CleanStaleFiles(resumeDir, 7*24*time.Hour); removed > 0 {
@@ -188,43 +143,10 @@ 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
- // Measure the real transcode ceiling instead of guessing from the backend.
- // HW encoders return 2160 instantly; a software-only host runs a bounded
- // encode benchmark so a weak NAS/CPU reports the rung it can actually
- // sustain (720/480) and the web side routes oversized sources to an
- // external player instead of a stuttering transcode. This blocks
- // registration on a software host, so it's bounded tight (3 rungs × 6 s =
- // 18 s worst case; <1 s on a capable box that passes the first rung). Own
- // timeout — the 10 s probeCtx above is sized for the quick diagnostic.
- benchCtx, benchCancel := context.WithTimeout(context.Background(), 20*time.Second)
- maxTranscodeHeight := engine.BenchmarkMaxTranscodeHeight(benchCtx, ffmpegResolved, hwAccelPick)
- benchCancel()
-
- // Warm the tonemap capability caches off the hot path. The libplacebo probe
- // actually RUNS the filter (Vulkan device init ~1.7 s), so doing it lazily
- // in buildTranscodeRuntime would tax the FIRST stream session and risk its
- // setup timeout. A real session arrives seconds-to-minutes after startup, so
- // a background warm has finished by then; if one races in first, the cache's
- // own mutex makes the concurrent cold call safe (both compute the same bool).
- if cfg.Download.Transcode.Enabled && ffmpegResolved != "" {
- go func() {
- engine.FFmpegSupportsLibplacebo(ffmpegResolved)
- engine.FFmpegSupportsZscale(ffmpegResolved)
- engine.FFmpegSupportsScaleCuda(ffmpegResolved)
- }()
+ hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath)
+ maxTranscodeHeight := 1080
+ if hwAccelPick != engine.HWAccelNone {
+ maxTranscodeHeight = 2160
}
// Create daemon config
@@ -234,20 +156,12 @@ func runDaemonStart() error {
Version: Version,
DownloadDir: cfg.Download.Dir,
StreamPort: cfg.Download.StreamPort,
- HTTPSStreamPort: cfg.Download.HTTPSStreamPort,
- AgentHash: cfg.Agent.Hash,
LanIP: engine.LanIP(),
TailscaleIP: engine.TailscaleIP(),
CanDelete: cfg.Library.AllowDelete,
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(),
- Downlink: cfg.Daemon.Downlink,
}
// Create HTTP client with mirror failover so a `.com` block-out rolls
@@ -273,9 +187,6 @@ func runDaemonStart() error {
metaTimeout, _ := time.ParseDuration(cfg.Download.MetadataTimeout)
stallTimeout, _ := time.ParseDuration(cfg.Download.StallTimeout)
- // Parse the seeding time target (0/"" = no time target — ratio-only or forever)
- seedTime, _ := time.ParseDuration(cfg.Download.SeedTime)
-
// Create progress reporter — only used for stream tasks (handleStreamTask)
// The sync goroutine handles all regular progress reporting.
statusInterval, _ := time.ParseDuration(cfg.Daemon.StatusInterval)
@@ -337,18 +248,15 @@ func runDaemonStart() error {
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
- DataDir: cfg.Download.Dir,
- PieceCompletionDir: config.DataDir(), // keep piece-completion DB off NFS/SMB mounts
- MetadataTimeout: metaTimeout,
- StallTimeout: stallTimeout,
- MaxTimeout: 0,
- MaxDownloadRate: maxDl,
- MaxUploadRate: maxUl,
- ListenPort: cfg.Download.ListenPort,
- SeedEnabled: cfg.Download.SeedEnabled,
- SeedRatio: cfg.Download.SeedRatio,
- SeedTime: seedTime,
- VPNTunnel: vpnTunnel,
+ DataDir: cfg.Download.Dir,
+ MetadataTimeout: metaTimeout,
+ StallTimeout: stallTimeout,
+ MaxTimeout: 0,
+ MaxDownloadRate: maxDl,
+ MaxUploadRate: maxUl,
+ ListenPort: cfg.Download.ListenPort,
+ SeedEnabled: false,
+ VPNTunnel: vpnTunnel,
})
if err != nil {
return fmt.Errorf("create torrent downloader: %w", err)
@@ -365,30 +273,8 @@ func runDaemonStart() error {
log.Printf("Speed limits: download=%s upload=%s", dlStr, ulStr)
}
- if cfg.Download.SeedEnabled {
- switch {
- case cfg.Download.SeedRatio > 0 && seedTime > 0:
- log.Printf("[torrent] seeding enabled (stop at ratio %.2f or %s, whichever first)", cfg.Download.SeedRatio, seedTime)
- case cfg.Download.SeedRatio > 0:
- log.Printf("[torrent] seeding enabled (stop at ratio %.2f)", cfg.Download.SeedRatio)
- case seedTime > 0:
- log.Printf("[torrent] seeding enabled (stop after %s)", seedTime)
- default:
- log.Printf("[torrent] seeding enabled (no ratio/time target — seeds until shutdown)")
- }
- }
-
// Create debrid downloader
debridDl := engine.NewDebridDownloader()
- usenetDl := engine.NewUsenetDownloader(agentClient)
-
- // Pre-flight disk reserve: refuse a download that would leave less than this
- // many bytes free, so a download never fills the filesystem to 0 mid-write.
- minFreeBytes := int64(cfg.Download.MinFreeDiskMB) << 20
- torrentDl.SetMinFreeBytes(minFreeBytes)
- debridDl.SetMinFreeBytes(minFreeBytes)
- usenetDl.SetMinFreeBytes(minFreeBytes)
- log.Printf("[disk] download free-space reserve: %d MiB", cfg.Download.MinFreeDiskMB)
// Create download manager
manager := engine.NewManager(engine.ManagerConfig{
@@ -401,158 +287,23 @@ func runDaemonStart() error {
TVShowsDir: cfg.Organize.TVShowsDir,
OutputDir: cfg.Download.Dir,
},
- }, reporter, torrentDl, debridDl, usenetDl)
-
- // Resume store: persist in-flight downloads so a daemon restart can re-submit
- // them (the downloaders resume the partial data). Wire it before any Submit.
- taskStore := agent.NewActiveTaskStore()
- manager.SetTaskStore(taskStore)
+ }, reporter, torrentDl, debridDl, engine.NewUsenetDownloader(agentClient))
// Create persistent stream server
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
- // Wire ffmpeg so /thumbnail can extract single frames for the web's "file
- // characteristics" panel (frames on demand). Empty = thumbnails 503.
- streamSrv.SetFFmpegPath(ffmpegResolved)
- // Write-through cache extracted WebVTT into the hidden ".unarr" sidecar dir so
- // /sub serves instantly (and giant remuxes that exceed the on-demand timeout
- // work once the scan prewarm has filled the cache). Default true.
- streamSrv.SetCacheSubtitles(cfg.Library.CacheSubtitles)
- streamSrv.SetCacheThumbnails(cfg.Library.CacheThumbnails)
- // Tell /trickplay which tile width the scan prewarm built the sprite at (the
- // agent owns the width; the web requests by path only). 0 = disabled → 404.
- trickW := 0
- if cfg.Library.Trickplay.Enabled {
- if trickW = cfg.Library.Trickplay.Width; trickW <= 0 {
- trickW = 240
- }
- }
- streamSrv.SetTrickplayWidth(trickW)
- // Self-heal a host→container base-path skew for the path-scoped handlers
- // (/thumbnail, /trickplay, /sub), mirroring the /stream + /hls remap. Without
- // it, a docker agent whose web DB holds host paths (/mnt/nas/peliculas/…) but
- // mounts that media at /downloads returns 404 for every scrubber frame /
- // trickplay sprite / external subtitle. Same allowed roots + relocate logic.
- // NOTE: relocateUnreachable needs a ≥3-segment path tail, so a FLAT media
- // layout (file directly under the root) is not self-healed here — those
- // sidecars 404 on a docker agent with a host→container skew until a re-scan
- // rewrites the DB path. Same limitation as the /stream self-heal.
- streamSrv.SetPathResolver(func(p string) string {
- p = filepath.Clean(p)
- roots := streamAllowedRoots(cfg)
- if isAllowedStreamPath(p, roots...) {
- return p
- }
- return relocateUnreachable(p, roots) // "" when not locatable → caller 404s
- })
- streamSrv.SetRequireStreamToken(cfg.Download.RequireStreamToken)
- // Report the stream-token signing key ONLY when enforcing, so the web's
- // "secret present → mint HLS token" signal accurately means "this agent
- // verifies tokens". Reporting it with enforcement off would make the web
- // mint HLS path tokens the agent never peels → 404. Set before Register().
- if cfg.Download.RequireStreamToken {
- d.UpdateStreamSecret(streamSrv.StreamSecretHex())
- }
- // 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)
-
- // HTTPS stream listener (per-agent direct-TLS): obtain/renew the cert from the
- // broker FIRST (broker runs ACME DNS-01 with our CSR; the private key never
- // leaves us), then arm the listener if a usable cert is on disk. Without a
- // valid cert there is nothing to serve over TLS, and the HTTP listener +
- // funnel keep working regardless.
- if cfg.Download.HTTPSStreamPort > 0 {
- if cfg.Agent.Hash != "" {
- // The broker's ownership check requires the agent to be registered
- // first (the agent_hash must live on THIS user's agent_registration
- // row). Register now — best-effort — so a fresh agent can get its cert
- // on the first boot; d.Run() registers again later (idempotent upsert).
- if err := d.Register(ctx); err != nil {
- log.Printf("[acme] pre-cert registration failed (%v) — cert will arrive on a later renewal tick", err)
- } else {
- fetchAgentCert(ctx, agentClient, cfg.Agent.Hash)
- }
- }
- keyPath, certPath := acme.Paths(config.DataDir())
- if err := streamSrv.LoadTLSCertificateFromFiles(certPath, keyPath); err != nil {
- log.Printf("[stream] HTTPS disabled — no usable certificate at %s (%v)", certPath, err)
- } else {
- streamSrv.EnableTLS(cfg.Download.HTTPSStreamPort)
- log.Printf("[stream] HTTPS armed on port %d with certificate %s", cfg.Download.HTTPSStreamPort, certPath)
- }
- }
+ 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)
}
d.UpdateStreamPort(streamSrv.Port())
- // Per-agent direct-TLS renewal: re-fetch the cert ahead of expiry and
- // hot-swap it into the live listener (no restart). Only meaningful once the
- // listener was armed at startup (a first-issuance that failed then needs a
- // daemon restart to arm). Cheap 6 h poll; NeedsIssue gates the actual fetch.
- if cfg.Download.HTTPSStreamPort > 0 && cfg.Agent.Hash != "" {
- go func() {
- t := time.NewTicker(6 * time.Hour)
- defer t.Stop()
- for {
- select {
- case <-ctx.Done():
- return
- case <-t.C:
- if !acme.NeedsIssue(config.DataDir()) {
- continue
- }
- fetchAgentCert(ctx, agentClient, cfg.Agent.Hash)
- keyPath, certPath := acme.Paths(config.DataDir())
- if err := streamSrv.LoadTLSCertificateFromFiles(certPath, keyPath); err != nil {
- log.Printf("[acme] hot-swap after renewal failed: %v", err)
- } else {
- log.Printf("[acme] renewed cert hot-swapped into live listener")
- }
- }
- }
- }()
- }
-
// CloudFlare Quick Tunnel — needs the ACTUAL listening port (the
// configured port may have been busy and bumped). Spawning here ensures
// cloudflared --url points at the right socket. Failures degrade to
@@ -579,19 +330,9 @@ func runDaemonStart() error {
sc.GetFreeSlots = manager.FreeSlots
sc.GetTaskStates = manager.TaskStates
d.GetActiveCount = manager.ActiveCount
- // Live stream count for the graceful auto-upgrade gate: player sessions
- // (in-browser HLS / direct-play / remux) + HLS transcode sessions. An auto
- // upgrade defers while this is > 0 so it never cuts a viewer mid-playback.
- d.GetActiveStreamCount = func() int {
- return playerSessionRegistry.count() + streamSrv.HLS().Count()
- }
// Trigger immediate sync when a download slot frees up
manager.OnTaskDone = func() { d.TriggerSync() }
- // Event-driven uplink: every status transition (resolving/downloading/
- // verifying/organizing/…) pushes to the server right away instead of waiting
- // for the next adaptive tick. Coalesced by TriggerSync's buffered-1 channel.
- manager.OnStateChange = func() { d.TriggerSync() }
// Wire: sync receives new tasks → submit to manager or handle stream
d.OnTasksClaimed = func(tasks []agent.Task) {
@@ -606,27 +347,13 @@ func runDaemonStart() error {
streamRegistry.mu.Lock()
streamRegistry.cancels[t.ID] = streamCancel
streamRegistry.mu.Unlock()
- go handleStreamTask(streamCtx, t, reporter, cfg, agentClient, streamSrv, func() { d.TriggerSync() })
+ go handleStreamTask(streamCtx, t, reporter, cfg, agentClient, streamSrv)
} else {
manager.Submit(ctx, t)
}
}
}
- // Resume downloads interrupted by the previous shutdown/crash. Re-submit
- // each persisted task; its downloader picks up the partial data (torrent via
- // the piece-completion DB, debrid via Range, usenet via its tracker). Done
- // before the sync loop starts; a later web re-dispatch of the same id is
- // deduped by the manager.
- if resume := taskStore.Load(); len(resume) > 0 {
- log.Printf("[resume] re-submitting %d interrupted download(s)", len(resume))
- for _, t := range resume {
- t.ForceStart = false // respect MaxConcurrent on bulk auto-resume
- log.Printf("[resume] %s — %s", agent.ShortID(t.ID), t.Title)
- manager.Submit(ctx, t)
- }
- }
-
// Wire: sync receives control signals → act on manager
d.OnControlAction = func(action, taskID string, deleteFiles bool) {
switch action {
@@ -687,14 +414,6 @@ func runDaemonStart() error {
}
}
- // Wire: sync receives on-demand subtitle-fetch jobs (write VTT sidecars).
- // Always available (additive, no deletion) as long as we have scan paths.
- if len(daemonCfg.ScanPaths) > 0 {
- sc.OnSubtitleFetch = func(reqs []agent.SubtitleFetchRequest) ([]int, []agent.SubtitleFetchError) {
- return library.FetchSubtitles(reqs, daemonCfg.ScanPaths)
- }
- }
-
// Wire: sync receives stream requests for completed downloads
d.OnStreamRequested = func(sr agent.StreamRequest) {
if streamSrv.CurrentTaskID() == sr.TaskID {
@@ -710,32 +429,53 @@ func runDaemonStart() error {
return
}
- // reportStreamError tells the web a /stream attempt failed WITHOUT
- // marking the download failed (StreamError, not Status). The web clears
- // streamRequested and surfaces this so the player fails fast with the
- // real reason instead of polling out the 20s "agent didn't respond".
- reportStreamError := func(reason string) {
+ filePath := filepath.Clean(sr.FilePath)
+ if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
+ cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
+ log.Printf("[%s] stream request rejected: path outside allowed dirs: %s", agent.ShortID(sr.TaskID), filePath)
go func() {
if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
- TaskID: sr.TaskID,
- StreamError: reason,
+ TaskID: sr.TaskID,
+ Status: "failed",
+ ErrorMessage: fmt.Sprintf("path outside allowed dirs: %s", filePath),
}); err != nil {
log.Printf("[%s] stream error report failed: %v", agent.ShortID(sr.TaskID), err)
}
}()
+ return
+ }
+ info, err := os.Stat(filePath)
+ if err != nil {
+ log.Printf("[%s] stream request: file not found: %s", agent.ShortID(sr.TaskID), filePath)
+ go func() {
+ if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
+ TaskID: sr.TaskID,
+ Status: "failed",
+ ErrorMessage: fmt.Sprintf("file not found: %s", filePath),
+ }); err != nil {
+ log.Printf("[%s] stream error report failed: %v", agent.ShortID(sr.TaskID), err)
+ }
+ }()
+ return
}
- // Self-heal a base-path mismatch: the web may hand us a path under an old
- // root (e.g. /mnt/nas/peliculas/… from before a binary→docker move) that
- // is now outside our allowed dirs but whose file still exists under a
- // current root (/downloads/…). resolvePlayableFile remaps, stat-retries
- // (NFS) and resolves directories; the next re-scan persists the fix to
- // the DB. See docs/plans/unarr-path-resilience.md.
- filePath, errCode, perr := resolvePlayableFile(sr.FilePath, streamAllowedRoots(cfg), agent.ShortID(sr.TaskID))
- if perr != nil {
- log.Printf("[%s] stream request rejected (%s): %v", agent.ShortID(sr.TaskID), errCode, perr)
- reportStreamError(perr.Error())
- return
+ if info.IsDir() {
+ found := engine.FindVideoFile(filePath)
+ if found == "" {
+ log.Printf("[%s] stream request: no video file in directory: %s", agent.ShortID(sr.TaskID), filePath)
+ go func() {
+ if _, err := agentClient.ReportStatus(ctx, agent.StatusUpdate{
+ TaskID: sr.TaskID,
+ Status: "failed",
+ ErrorMessage: fmt.Sprintf("no video file in directory: %s", filePath),
+ }); err != nil {
+ log.Printf("[%s] stream error report failed: %v", agent.ShortID(sr.TaskID), err)
+ }
+ }()
+ return
+ }
+ filePath = found
+ log.Printf("[%s] resolved directory to video file: %s", agent.ShortID(sr.TaskID), filepath.Base(filePath))
}
cancelStreamContexts()
@@ -766,275 +506,52 @@ func runDaemonStart() error {
if playerSessionRegistry.has(sess.SessionID) {
return // already running
}
-
- // failSession logs AND reports a startup failure to the web — every
- // abort path in this handler must go through it. A silent `return`
- // here left the player probing a playlist that would never exist
- // until its 30s deadline (incident 2026-06-10: deleted file + stale
- // library row = eternal "Preparando sesión"). Best-effort: on old web
- // deployments the endpoint 404s and the player falls back to the
- // probe deadline, exactly as before.
- failSession := func(sessionID, code, message string) {
- log.Printf("[hls %s] failed (%s): %s", agent.ShortID(sessionID), code, message)
- go func() {
- // Fresh context on purpose: failures cluster exactly when the
- // daemon ctx is being cancelled (shutdown kills in-flight
- // session starts), and a report derived from it would die
- // before reaching the web. The 10s cap still bounds it.
- rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := agentClient.ReportSessionError(rctx, sessionID, code, message); err != nil {
- log.Printf("[hls %s] session error report failed: %v", agent.ShortID(sessionID), err)
- }
- }()
- }
-
- // markReady reports "first bytes are servable" for the no-transcode
- // paths (direct-play, remux, debrid direct) — one place instead of a
- // copy per branch. HLS sessions report via watchSessionReady instead
- // (they wait for seg-0 + attach a health snapshot).
- markReady := func(sessionID string) {
- go func() {
- rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
- defer cancel()
- if err := agentClient.MarkSessionReady(rctx, sessionID, nil); err != nil {
- log.Printf("[stream %s] mark-ready failed: %v", agent.ShortID(sessionID), err)
- }
- }()
- }
-
- // startHLSPlayback starts an HLS encode (local file or debrid URL) and
- // wires it into the StreamServer. Shared by the local-file HLS path and
- // the debrid HLS-from-URL path (hueco #2 / 2b) so both register, probe
- // off the sync loop, and report readiness identically.
- //
- // Prewarm sessions (background cache-fill: next-episode, hover) take a
- // deferential path: wait until no live encode is running (never steal
- // the encoder from the viewer), then register WITHOUT displacing other
- // sessions. Before this, a prewarm claimed mid-playback went through
- // Register() and KILLED the stream the user was watching (verified
- // 2026-06-10: prewarm started → live session "closed (cache
- // discarded)" → player 404).
- startHLSPlayback := func(hlsCfg engine.HLSSessionConfig, hlsCtx context.Context, hlsCancel context.CancelFunc) {
- playerSessionRegistry.add(hlsCfg.SessionID, hlsCancel)
- prewarm := sess.Prewarm
- go func() {
- if prewarm {
- // Defer until the encoder is free. Poll is cheap (10 s);
- // cap the wait at 30 min — a prewarm that can't start
- // within an episode's runtime has lost its purpose.
- deadline := time.Now().Add(30 * time.Minute)
- for streamSrv.HLS().HasLiveEncode() {
- if time.Now().After(deadline) || hlsCtx.Err() != nil {
- playerSessionRegistry.remove(hlsCfg.SessionID)
- hlsCancel()
- log.Printf("[hls %s] prewarm abandoned (encoder busy %s)",
- agent.ShortID(hlsCfg.SessionID), "30m")
- return
- }
- select {
- case <-hlsCtx.Done():
- playerSessionRegistry.remove(hlsCfg.SessionID)
- return
- case <-time.After(10 * time.Second):
- }
- }
- } else {
- // REAL session: reap in-flight prewarm encodes BEFORE
- // StartHLSSession so the per-key cache writer-lock is
- // free and the viewer's encode lands in the persistent
- // cache (not an uncached tmpdir). A SEALED prewarm is
- // unaffected — this session simply cache-HITs it.
- if n := streamSrv.HLS().CloseWhere(func(s *engine.HLSSession) bool { return s.IsPrewarm() }); n > 0 {
- log.Printf("[hls %s] reaped %d in-flight prewarm(s) for the viewer session",
- agent.ShortID(hlsCfg.SessionID), n)
- }
- }
- hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
- if err != nil {
- playerSessionRegistry.remove(hlsCfg.SessionID)
- hlsCancel()
- failSession(hlsCfg.SessionID, sessErrStartFailed, err.Error())
- return
- }
- if prewarm {
- // Side-by-side: never evict the viewer's session. A later
- // REAL session still evicts this one via Register — by
- // then the encode is usually sealed in the segment cache.
- streamSrv.HLS().RegisterKeep(hsess)
- log.Printf("[hls %s] prewarm encoding: %s", agent.ShortID(hlsCfg.SessionID), hlsCfg.FileName)
- return // no viewer waiting → no ready-watcher
- }
- streamSrv.HLS().Register(hsess)
- go watchSessionReady(hlsCtx, agentClient, hsess, hlsCfg.SessionID)
- }()
- }
-
- // Debrid direct-play (hueco #2 / 2a): the source has no local file — the
- // web resolved an HTTPS debrid link (cache-confirmed, browser-native
- // container) and the daemon streams /stream from it via ranged GETs.
- // Runs BEFORE the filePath checks (there is no local path) and needs no
- // ffmpeg. PlayMethod != "hls" distinguishes this from the debrid
- // HLS-from-URL branch below (a non-native container the web wants
- // transcoded). Provider setup does a HEAD, so hand it off to a goroutine
- // to keep the sync loop from blocking other pending actions; register the
- // session up front so a duplicate sync within the setup window is a
- // no-op (matches the HLS branch's handoff rationale).
- if sess.DirectURL != "" && sess.PlayMethod != "hls" {
- playerSessionRegistry.add(sess.SessionID, func() { streamSrv.ClearFile() })
- // refresh re-resolves a fresh debrid link when this one expires
- // mid-stream (hueco #2 / 2c). Bound to the daemon ctx so a shutdown
- // cancels an in-flight refresh.
- refresh := func(rctx context.Context) (string, error) {
- return agentClient.RefreshStreamURL(rctx, sess.SessionID)
- }
- go func() {
- bctx, cancel := context.WithTimeout(ctx, 15*time.Second)
- defer cancel()
- provider, perr := engine.NewDebridFileProvider(bctx, sess.DirectURL, sess.FileName, sess.FileSize, refresh)
- if perr != nil {
- playerSessionRegistry.remove(sess.SessionID)
- failSession(sess.SessionID, sessErrStartFailed, fmt.Sprintf("debrid provider: %v", perr))
- return
- }
- streamSrv.SetFile(provider, sess.TaskID)
- log.Printf("[stream %s] debrid direct-play: %s (%d bytes)",
- agent.ShortID(sess.SessionID), provider.FileName(), provider.FileSize())
- markReady(sess.SessionID)
- }()
+ filePath := sess.FilePath
+ if filePath == "" {
+ log.Printf("[hls %s] rejected: empty file path", agent.ShortID(sess.SessionID))
return
}
-
- // Debrid HLS-from-URL (hueco #2 / 2b): the source is debrid-cached but
- // NOT browser-native (mkv/HEVC/…), so the web set playMethod="hls"
- // alongside the DirectURL. ffmpeg transcodes straight from the HTTP URL —
- // no local file, no torrent. Cache is keyed by info_hash (not the
- // per-resolution URL) so a re-play hits the segment cache.
- if sess.DirectURL != "" { // playMethod == "hls" implied (2a returned above)
- tcRuntime := buildTranscodeRuntime(ctx, cfg)
- if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
- failSession(sess.SessionID, sessErrFfmpegMissing, "ffmpeg/ffprobe unavailable (debrid HLS)")
+ filePath = filepath.Clean(filePath)
+ if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
+ cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
+ log.Printf("[hls %s] rejected: path outside allowed dirs: %s",
+ agent.ShortID(sess.SessionID), filePath)
+ return
+ }
+ // Resolve directory → first video file (matches StreamRequest behavior).
+ if info, err := os.Stat(filePath); err == nil && info.IsDir() {
+ found := engine.FindVideoFile(filePath)
+ if found == "" {
+ log.Printf("[hls %s] rejected: no video file in dir %s",
+ agent.ShortID(sess.SessionID), filePath)
return
}
- hlsCtx, hlsCancel := context.WithCancel(ctx)
- startHLSPlayback(engine.HLSSessionConfig{
- SessionID: sess.SessionID,
- SourceURL: sess.DirectURL,
- CacheID: sess.InfoHash,
- FileName: sess.FileName,
- Quality: sess.Quality,
- AudioIndex: sess.AudioIndex,
- BurnSubtitleIndex: sess.BurnSubtitleIndex,
- StartSec: sess.StartSec,
- Prewarm: sess.Prewarm,
- Transcode: tcRuntime,
- Cache: hlsCache,
- // 2c: refresh the debrid link if it expires mid-transcode; the
- // auto-restart supervisor calls this before relaunching ffmpeg.
- RefreshURL: func(rctx context.Context) (string, error) {
- return agentClient.RefreshStreamURL(rctx, sess.SessionID)
- },
- }, hlsCtx, hlsCancel)
- log.Printf("[hls %s] debrid HLS-from-URL: %s", agent.ShortID(sess.SessionID), sess.FileName)
- return
- }
-
- if sess.FilePath == "" {
- failSession(sess.SessionID, sessErrStartFailed, "empty file path")
- return
- }
- // SAME base-path self-heal + stat-retry + dir resolution as the raw
- // /stream handler (resolvePlayableFile). A path under an old/host base
- // (e.g. /mnt/nas/peliculas/… handed by the web while this docker agent
- // mounts that media at /downloads) remaps onto the current root; a path
- // whose file is genuinely gone fails fast as "file_missing" so the web
- // can prune the stale library row and the player can fall back, instead
- // of the player probing a playlist that will never exist.
- // See docs/plans/unarr-path-resilience.md.
- filePath, errCode, perr := resolvePlayableFile(sess.FilePath, streamAllowedRoots(cfg), "hls "+agent.ShortID(sess.SessionID))
- if perr != nil {
- failSession(sess.SessionID, errCode, perr.Error())
- return
- }
-
- // Direct-play (hueco #3 / 3a): the web decided this source is already
- // browser-native (mp4 h264/aac 8-bit SDR) from library scan metadata,
- // gated on agent version. Serve the raw file over /stream (HTTP Range,
- // no ffmpeg) instead of transcoding to HLS — zero CPU, instant seek.
- // Runs BEFORE the ffmpeg-availability check on purpose: direct-play
- // needs no ffmpeg, so it must work even when transcode is disabled.
- if sess.PlayMethod == "direct" {
- streamSrv.SetFile(engine.NewDiskFileProvider(filePath), sess.TaskID)
- // cancel just clears the served file so daemon shutdown / drain
- // stops exposing it on /stream. There's no ffmpeg child to kill.
- playerSessionRegistry.add(sess.SessionID, func() { streamSrv.ClearFile() })
- log.Printf("[stream %s] direct-play: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
- // File is on disk → ready immediately. Tell the web so the player
- // attaches without burning its HEAD-probe retry budget.
- markReady(sess.SessionID)
- return
+ filePath = found
}
tcRuntime := buildTranscodeRuntime(ctx, cfg)
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
- failSession(sess.SessionID, sessErrFfmpegMissing, "ffmpeg/ffprobe unavailable")
+ log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
return
}
-
- // Remux path (hueco #3 / 3b): codecs are browser-native (h264/aac) but
- // the container isn't (mkv). ffmpeg `-c copy` → growing fMP4 served raw
- // over /stream — no video re-encode, no HLS. The web decided this from
- // scan metadata + version gate; we still need ffmpeg (copy uses it).
- if sess.PlayMethod == "remux" {
- tStart := time.Now()
- probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second)
- probe, perr := engine.ProbeFile(probeCtx, tcRuntime.FFprobePath, filePath)
- cancelProbe()
- if perr != nil {
- failSession(sess.SessionID, sessErrStartFailed, fmt.Sprintf("remux probe: %v", perr))
- return
- }
- tProbe := time.Now()
- remuxCtx, remuxCancel := context.WithCancel(ctx)
- src, serr := engine.NewRemuxSource(remuxCtx, filePath, probe, tcRuntime.FFmpegPath, sess.FileName)
- if serr != nil {
- remuxCancel()
- failSession(sess.SessionID, sessErrStartFailed, fmt.Sprintf("remux start: %v", serr))
- return
- }
- streamSrv.SetGrowingFile(src, sess.TaskID)
- // cancel stops the ffmpeg copy; SetGrowingFile/ClearFile also Close()
- // the source, so the temp file is always cleaned up.
- playerSessionRegistry.add(sess.SessionID, func() { remuxCancel(); streamSrv.ClearFile() })
- // Startup timing (TTFF diagnosis): probe = ffprobe on the source;
- // spawn = ffmpeg launch + tmp setup. First-fMP4-byte is logged by the
- // source itself; serveGrowing logs any client read that blocks waiting
- // for ffmpeg to catch up.
- log.Printf("[stream %s] remux (copy) → fMP4: %s [probe=%v spawn=%v]",
- agent.ShortID(sess.SessionID), filepath.Base(filePath),
- tProbe.Sub(tStart).Round(time.Millisecond), time.Since(tProbe).Round(time.Millisecond))
- markReady(sess.SessionID)
- return
- }
-
- // Local-file HLS (the original path). StartHLSSession runs ffprobe
- // (15 s cap) inside startHLSPlayback's goroutine so the sync loop
- // returns immediately — browser HEAD probes have a 30 s retry budget
- // that absorbs the gap until the playlist registers.
hlsCtx, hlsCancel := context.WithCancel(ctx)
- startHLSPlayback(engine.HLSSessionConfig{
- SessionID: sess.SessionID,
- SourcePath: filePath,
- FileName: sess.FileName,
- Quality: sess.Quality,
- AudioIndex: sess.AudioIndex,
- BurnSubtitleIndex: sess.BurnSubtitleIndex,
- StartSec: sess.StartSec,
- Prewarm: sess.Prewarm,
- VideoCopy: sess.VideoCopy,
- Transcode: tcRuntime,
- Cache: hlsCache,
- }, hlsCtx, hlsCancel)
+ playerSessionRegistry.add(sess.SessionID, hlsCancel)
+ hlsCfg := engine.HLSSessionConfig{
+ SessionID: sess.SessionID,
+ SourcePath: filePath,
+ FileName: sess.FileName,
+ Quality: sess.Quality,
+ AudioIndex: sess.AudioIndex,
+ Transcode: tcRuntime,
+ }
+ 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)
@@ -1073,7 +590,7 @@ func runDaemonStart() error {
// Start auto-scan goroutine
scanPaths := daemonCfg.ScanPaths
if len(scanPaths) > 0 && cfg.Library.AutoScan {
- scanInterval := time.Hour
+ scanInterval := 24 * time.Hour
if cfg.Library.ScanInterval != "" {
if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 {
scanInterval = parsed
@@ -1085,26 +602,6 @@ func runDaemonStart() error {
// Start reporter only for stream task handling
go reporter.Run(ctx)
- // Credential revoked mid-run (agent deleted from the dashboard): wipe the
- // stored key + agentId so a supervisor restart can't loop on a rejected
- // identity, then stop the daemon. Reconnecting needs a fresh `unarr login`.
- d.SyncClient().OnRevoked = func(err error) {
- reportAgentRevoked(cfg, err)
- cancel()
- }
-
- // Legacy bootstrap: if register hands back a per-machine key, persist it so
- // the next start authenticates with the bound agent key (one-time migration;
- // also stops the server re-minting on every restart).
- d.OnAgentKeyMinted = func(newKey string) {
- cfg.Auth.APIKey = newKey
- if serr := config.Save(cfg, resolvedConfigPath()); serr != nil {
- log.Printf("[agent] could not persist per-machine key: %v", serr)
- } else {
- log.Printf("[agent] migrated to a per-machine agent key")
- }
- }
-
// Start daemon (blocks — runs sync loop)
errCh := make(chan error, 1)
go func() {
@@ -1125,16 +622,13 @@ func runDaemonStart() error {
cancelStreamContexts()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background())
+ cancel()
- // Drain active downloads BEFORE cancelling the daemon context. Shutdown
- // sets shuttingDown + cancels each task context itself, so interrupted
- // downloads keep their resume-store entry. Cancelling the shared ctx first
- // would make them look like genuine failures and wipe the entry → no resume.
+ // Give active downloads 30s to finish
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
manager.Shutdown(shutdownCtx)
- cancel()
d.Deregister()
fmt.Println(" Daemon stopped.")
return nil
@@ -1144,48 +638,14 @@ func runDaemonStart() error {
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background())
cancel()
- // Registration was rejected because this agent's credential is revoked
- // (deleted from the dashboard). Wipe it and exit cleanly so the service
- // supervisor doesn't restart-loop against a 410; user must re-login.
- if agent.IsRevoked(err) {
- reportAgentRevoked(cfg, err)
- return nil
- }
return err
}
}
-// reportAgentRevoked tells the user their agent was removed and wipes the
-// stored credential (api key + agentId) so the next start requires a fresh
-// `unarr login` (which mints a new per-machine key bound to a new agentId)
-// instead of looping against a server that keeps rejecting the old identity.
-func reportAgentRevoked(cfg config.Config, err error) {
- log.Printf("[agent] credential revoked by server (%v) — this machine was removed from your account", err)
- cfg.Auth.APIKey = ""
- cfg.Agent.ID = ""
- if serr := config.Save(cfg, resolvedConfigPath()); serr != nil {
- log.Printf("[agent] could not clear stored credential: %v", serr)
- }
- fmt.Println()
- fmt.Println(" This agent was removed from your account.")
- fmt.Println(" Run `unarr login` on this machine to reconnect it.")
- fmt.Println()
-}
-
// isAllowedStreamPath checks that filePath is within one of the directories
// the daemon is configured to manage. This defends against a compromised API
// server sending a path traversal payload (e.g. /etc/passwd) in StreamRequest.
// isAllowedStreamPath reports whether filePath is contained within one of the
-// streamAllowedRoots returns the directory roots a stream / sidecar path is
-// permitted under. Single source of truth so the raw /stream, HLS, and
-// path-scoped (/thumbnail, /trickplay, /sub) handlers never disagree about what
-// is reachable — a root added to one place but not the others would otherwise
-// produce confusing partial failures (stream plays, scrubber frames 404).
-func streamAllowedRoots(cfg config.Config) []string {
- return []string{cfg.Download.Dir, cfg.Library.ScanPath,
- cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir}
-}
-
// allowedDirs. filePath must already be cleaned (filepath.Clean) by the caller.
// This defends against a compromised API server sending a path traversal payload.
func isAllowedStreamPath(filePath string, allowedDirs ...string) bool {
@@ -1201,129 +661,6 @@ func isAllowedStreamPath(filePath string, allowedDirs ...string) bool {
return false
}
-// relocateUnreachable tries to find a file the web asked us to stream under a
-// path we can't serve (e.g. an old base path) by joining the longest suffix of
-// that path onto each current allowed root and checking it exists. Returns the
-// found absolute path or "".
-//
-// Conservative by design — it must never serve the WRONG file:
-// - Requires a tail of at least three segments (collection/season/file), so a
-// generic "Season 01/Episode.mkv" can't match a different show by accident.
-// Flat single-file-at-root layouts simply aren't self-healed here; the next
-// re-scan re-maps them instead.
-// - Re-checks containment AFTER resolving symlinks, so a symlink inside a root
-// pointing outside it can't be used to escape the allowed dirs (isAllowed‑
-// StreamPath alone is a lexical check that os.Stat would happily follow out).
-func relocateUnreachable(filePath string, allowedRoots []string) string {
- segs := strings.Split(filepath.ToSlash(filePath), "/")
- // Longest tail first (most specific match wins). Stop before 3-segment tails
- // so a short, ambiguous suffix can't match the wrong file.
- for start := 0; start <= len(segs)-3; start++ {
- tail := filepath.Join(segs[start:]...)
- if tail == "" {
- continue
- }
- for _, root := range allowedRoots {
- if root == "" {
- continue
- }
- cand := filepath.Join(root, tail)
- if !isAllowedStreamPath(cand, root) {
- continue
- }
- fi, err := os.Stat(cand)
- if err != nil || fi.IsDir() {
- continue
- }
- // Re-validate containment against the symlink-resolved real paths so
- // a symlink under the root can't point the stream outside it.
- realCand, e1 := filepath.EvalSymlinks(cand)
- realRoot, e2 := filepath.EvalSymlinks(root)
- if e1 != nil || e2 != nil || !isAllowedStreamPath(realCand, realRoot) {
- continue
- }
- return cand
- }
- }
- return ""
-}
-
-// Stable machine codes for the web's session-error channel
-// (POST /api/internal/agent/session-error) — mirrored by
-// SESSION_ERROR_CODES in the web repo. Only "file_missing" triggers
-// destructive self-heal on the web (it prunes the stale library row + task
-// pointer), so the resolver must never return it while the file may exist.
-const (
- pathErrRejected = "path_rejected"
- pathErrMissing = "file_missing"
- pathErrNoVideo = "no_video_file"
- sessErrFfmpegMissing = "ffmpeg_unavailable"
- sessErrStartFailed = "start_failed"
-)
-
-// resolvePlayableFile validates and self-heals a web-provided source path into
-// a playable on-disk video file. Shared by the raw /stream handler and every
-// session transport (HLS / remux / direct-play) so they all behave
-// identically — before this, the HLS path replicated only the lexical remap
-// and silently diverged on stat retries (docs/plans/unarr-path-resilience.md):
-//
-// 1. Containment: the cleaned path must live under an allowed root; if not,
-// relocate it by path tail (old base path → current mount).
-// 2. Existence: os.Stat with retries (NFS can transiently fail right after a
-// remount or under load — the root of the "works on the 3rd try" stream
-// failures), then one last relocate for files that moved within a root.
-// 3. Directories resolve to their first contained video file.
-//
-// On failure returns a stable errCode: "path_rejected" means the file EXISTS
-// at the original path but outside every allowed root (an agent config
-// problem — the web must NOT prune library rows over it); "file_missing"
-// means no readable file was found anywhere; "no_video_file" is a directory
-// with nothing playable inside.
-func resolvePlayableFile(rawPath string, allowedRoots []string, logLabel string) (string, string, error) {
- filePath := filepath.Clean(rawPath)
- if !isAllowedStreamPath(filePath, allowedRoots...) {
- if remapped := relocateUnreachable(filePath, allowedRoots); remapped != "" {
- log.Printf("[%s] stream self-heal: remapped %s → %s", logLabel, filePath, remapped)
- filePath = remapped
- } else if _, err := os.Stat(filePath); err == nil {
- return "", pathErrRejected, fmt.Errorf("path outside allowed dirs: %s", filePath)
- } else {
- return "", pathErrMissing, fmt.Errorf("file not found under any allowed root: %s", filePath)
- }
- }
- var info os.FileInfo
- var statErr error
- for attempt := 0; attempt < 3; attempt++ {
- if info, statErr = os.Stat(filePath); statErr == nil {
- break
- }
- if attempt < 2 {
- time.Sleep(300 * time.Millisecond)
- }
- }
- if statErr != nil {
- // Last resort before failing: the file may simply have moved within
- // an allowed root — try to relocate it by path tail.
- if remapped := relocateUnreachable(filePath, allowedRoots); remapped != "" {
- log.Printf("[%s] stream self-heal: relocated missing %s → %s", logLabel, filePath, remapped)
- filePath = remapped
- info, statErr = os.Stat(filePath)
- }
- }
- if statErr != nil {
- return "", pathErrMissing, fmt.Errorf("file not found after retries: %s (%v)", filePath, statErr)
- }
- if info.IsDir() {
- found := engine.FindVideoFile(filePath)
- if found == "" {
- return "", pathErrNoVideo, fmt.Errorf("no video file in directory: %s", filePath)
- }
- log.Printf("[%s] resolved directory to video file: %s", logLabel, filepath.Base(found))
- filePath = found
- }
- return filePath, "", nil
-}
-
func formatSpeedLog(bps int64) string {
switch {
case bps >= 1024*1024*1024:
@@ -1340,23 +677,6 @@ func formatSpeedLog(bps int64) string {
// runAutoScan runs a library scan + sync on a timer or on-demand via scanNow channel.
// It scans all provided paths and syncs each independently so stale-item cleanup
// is scoped to the correct directory prefix on the server.
-// basePathChanged reports whether the library's scan root moved since the last
-// saved cache — i.e. the previously-scanned root is no longer one of the current
-// scan paths. Used to force a full (non-incremental) re-scan so the server can
-// re-map paths by fingerprint and reap the old prefix.
-func basePathChanged(existing *library.LibraryCache, scanPaths []string) bool {
- if existing == nil || len(existing.Items) == 0 || existing.Path == "" {
- return false
- }
- prev := filepath.Clean(existing.Path)
- for _, p := range scanPaths {
- if filepath.Clean(p) == prev {
- return false
- }
- }
- return true
-}
-
func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client, scanNow <-chan struct{}, scanPaths []string) {
log.Printf("[auto-scan] enabled: every %s, paths: %v", interval, scanPaths)
@@ -1382,99 +702,52 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
workers = 8
}
- // If the library base path changed (e.g. the agent moved from the host
- // binary to docker, remapping /mnt/nas/peliculas → /downloads, or the
- // user moved their media folder), force a FULL re-scan instead of an
- // incremental one. The fingerprint merge on the server then relocates
- // existing rows in place rather than duplicating, and per-agent cleanup
- // reaps the old prefix. See docs/plans/unarr-path-resilience.md.
- forceFull := basePathChanged(existing, scanPaths)
- if forceFull {
- log.Printf("[auto-scan] WARNING: library base path changed (was %q, now %v) — "+
- "running a FULL re-scan. This can take a while on large libraries; "+
- "playback and matches are preserved.", existing.Path, scanPaths)
- }
-
scanOpts := library.ScanOptions{
Workers: workers,
FFprobePath: cfg.Library.FFprobePath,
- Incremental: existing != nil && !forceFull,
+ Incremental: existing != nil,
}
- // Resolve ffmpeg once for the sidecar prewarm (extracts text subs → WebVTT
- // and panel thumbnail frames → JPEG into the hidden ".unarr" cache so /sub
- // and /thumbnail are instant + huge remuxes work). Empty/err = prewarm is
- // skipped silently (on-demand extraction still runs).
- prewarmFFmpeg := ""
- if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails || cfg.Library.Trickplay.Enabled {
- if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil {
- prewarmFFmpeg = ff
- } else {
- log.Printf("[auto-scan] sidecar prewarm disabled: ffmpeg unavailable: %v", err)
- }
- }
-
- // Scan every path, then sync ALL of them as ONE session (single
- // syncStartedAt + final isLastBatch via library.SyncBatches). Per-root
- // sessions let the server's per-agent stale cleanup reap rows of roots
- // a session never visited; one full-cycle session makes the cleanup
- // sound AND lets it reap old-base-path ghost rows (fullCycle=true —
- // only when every root scanned cleanly).
- var syncItems []agent.LibrarySyncItem
- var coveredRoots []string
- fullCycle := true
+ // Scan each path independently and sync per path so the server can
+ // scope stale-item deletion to the correct directory prefix.
+ const batchSize = 100
+ totalSynced := 0
var mergedItems []library.LibraryItem
for _, scanPath := range scanPaths {
cache, err := library.Scan(ctx, scanPath, existing, scanOpts)
if err != nil {
log.Printf("[auto-scan] scan failed for %s: %v", scanPath, err)
- fullCycle = false
continue
}
mergedItems = append(mergedItems, cache.Items...)
- coveredRoots = append(coveredRoots, scanPath)
-
- if prewarmFFmpeg != "" {
- library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{
- FFmpegPath: prewarmFFmpeg,
- CacheSubtitles: cfg.Library.CacheSubtitles,
- CacheThumbnails: cfg.Library.CacheThumbnails,
- Workers: 2,
- Trickplay: cfg.Library.Trickplay.Enabled,
- TrickplayIntervalSec: cfg.Library.Trickplay.IntervalSeconds(),
- TrickplayWidth: cfg.Library.Trickplay.Width,
- MaxLoadRatio: cfg.Library.PrewarmMaxLoadRatio,
- })
- }
items := library.BuildSyncItems(cache)
if len(items) == 0 {
log.Printf("[auto-scan] no items under %s", scanPath)
continue
}
- syncItems = append(syncItems, items...)
- }
- totalSynced := 0
- if len(syncItems) > 0 {
- res, err := library.SyncBatches(ctx, ac, syncItems, library.SyncOptions{
- AgentID: cfg.Agent.ID,
- ScanPath: coveredRoots[0],
- ScanRoots: coveredRoots,
- FullCycle: fullCycle,
- })
- if err != nil {
- log.Printf("[auto-scan] sync failed: %v", err)
- } else if res.Removed > 0 {
- log.Printf("[auto-scan] server removed %d stale item(s)", res.Removed)
+ syncStartedAt := time.Now().UTC().Format(time.RFC3339)
+ for i := 0; i < len(items); i += batchSize {
+ end := i + batchSize
+ if end > len(items) {
+ end = len(items)
+ }
+ isLast := end >= len(items)
+
+ _, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
+ Items: items[i:end],
+ ScanPath: scanPath,
+ IsLastBatch: isLast,
+ SyncStartedAt: syncStartedAt,
+ })
+ if err != nil {
+ log.Printf("[auto-scan] sync failed for %s: %v", scanPath, err)
+ break
+ }
}
- totalSynced = res.Synced
- } else {
- // An entirely-empty library can't open a sync session (the server
- // requires ≥1 item per batch), so stale rows survive until a file
- // reappears — same trade-off as before, now explicit.
- log.Printf("[auto-scan] no items under any scan path — skipping sync")
+ totalSynced += len(items)
}
// Save merged cache for incremental scanning next time.
@@ -1487,9 +760,6 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
if err := library.SaveCache(mergedCache); err != nil {
log.Printf("[auto-scan] save cache failed: %v", err)
}
- // Intro/credits detection AFTER the sync above — the server maps
- // file paths to the library_item rows that sync just created.
- detectAndSubmitSkipSegments(ctx, cfg, ac, mergedCache)
}
log.Printf("[auto-scan] synced %d items across %d path(s)", totalSynced, len(scanPaths))
@@ -1564,276 +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()
- readyPosted := false
- // `kind` labels the log line: a failed health re-post must not read as a
- // failed READY (the ready already landed — whoever debugs an eternal
- // "Preparando…" would chase the wrong webhook). Returns the error so the
- // F1 monitor only advances its baseline on a post that actually landed.
- postReady := func(health *agent.SessionHealth, kind string) error {
- // 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)
- err := client.MarkSessionReady(rctx, sessionID, health)
- if err != nil {
- log.Printf("[hls %s] %s failed: %v", agent.ShortID(sessionID), kind, err)
- }
- cancel()
- return err
- }
- 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
- }
- // Phase 1: cache HIT or first segment ready → flip the "Preparando…"
- // UI now. Compare against WriterStartIdx, not `>= 1`: a resume
- // session (StartSec) pre-seeds readyMax to the start index, so
- // ReadyCount() is ≥ 1 before ffmpeg has written a single byte —
- // `>= 1` would fire "ready" instantly and freeze the player waiting
- // on a segment that doesn't exist yet.
- if !readyPosted && (hsess.FromCache() || hsess.ReadyCount() > hsess.WriterStartIdx()) {
- _ = postReady(nil, "mark-ready")
- readyPosted = true
- // Cache replay has no live encode → no telemetry to report, done.
- if hsess.FromCache() {
- return
- }
- // HLS-copy session (F2): ffmpeg copies I/O-bound and emits no
- // usable -stats, so there is nothing to monitor — but a one-shot
- // "copy" heartbeat lets the web tell "copy session, producer
- // fine" from "old agent, no telemetry" (both read as null before).
- // Deliberately a SECOND post (not merged into the ready one): an
- // older web rejects the unknown "copy" reason with a 400, and that
- // must never block the ready flip itself.
- if hsess.IsVideoCopy() {
- _ = postReady(&agent.SessionHealth{Health: "ok", RealtimeRatio: 1, Reason: "copy"}, "copy health heartbeat (ready already marked)")
- return
- }
- }
- // Phase 2 (F3): once enough -stats samples accumulated (encoder past
- // its cold ramp), report the first live-health snapshot so the player
- // can name a too-slow transcode in ~4s instead of inferring it from
- // stalls — then keep MONITORING (F1): an encode that is fine in
- // minute 1 can fall behind in minute 20 (complex scene, GPU stolen by
- // another process) and the player's stall triage needs the fresh
- // ratio, not the boot-time snapshot.
- // >=4 samples ≈ 2s of encoding past seg-0; the EWMA has settled by then.
- if readyPosted {
- if st := hsess.GetTranscodeStats(); st.Samples >= 4 {
- first := classifyAgentHealth(st)
- if postReady(first, "health snapshot (ready already marked)") != nil {
- // Snapshot never landed — hand the monitor an empty
- // baseline so its first tick re-posts instead of
- // believing the web already knows this state.
- first = &agent.SessionHealth{}
- }
- monitorSessionHealth(ctx, hsess, sessionID, first, postReady)
- return
- }
- }
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- }
- if time.Now().After(deadline) {
- if !readyPosted {
- log.Printf("[hls %s] mark-ready: timeout waiting for seg-0", agent.ShortID(sessionID))
- return
- }
- // Ready but never got stable telemetry — report whatever we have so
- // the player isn't left without a verdict (better partial than none).
- if st := hsess.GetTranscodeStats(); st.Samples > 0 {
- _ = postReady(classifyAgentHealth(st), "late health snapshot (ready already marked)")
- }
- return
- }
- }
-}
-
-// agentTLSBaseDomain is the zone the cert broker issues per-agent wildcards
-// under. Overridable for staging via UNARR_AGENT_TLS_BASE.
-func agentTLSBaseDomain() string {
- if v := os.Getenv("UNARR_AGENT_TLS_BASE"); v != "" {
- return v
- }
- return "agent.unarr.app"
-}
-
-// fetchAgentCert obtains (or renews) the per-agent TLS cert from the web broker
-// and writes it to the agent state dir. The agent's private key never leaves the
-// machine — only a CSR is sent. Failure is non-fatal: HTTPS stays off and the
-// HTTP listener + CloudFlare funnel keep serving.
-func fetchAgentCert(ctx context.Context, client *agent.Client, hash string) {
- dataDir := config.DataDir()
- if !acme.NeedsIssue(dataDir) {
- return
- }
- base := agentTLSBaseDomain()
- csr, err := acme.BuildCSR(dataDir, hash, base)
- if err != nil {
- log.Printf("[acme] build CSR failed: %v", err)
- return
- }
- cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
- defer cancel()
- cert, err := client.IssueCert(cctx, csr)
- if err != nil {
- log.Printf("[acme] cert issuance failed (HTTPS stays off, funnel still works): %v", err)
- return
- }
- if err := acme.WriteCert(dataDir, cert); err != nil {
- log.Printf("[acme] write cert failed: %v", err)
- return
- }
- log.Printf("[acme] installed cert for *.%s.%s", hash, base)
-}
-
-// monitorSessionHealth keeps re-posting the live transcode health for the
-// rest of the session (F1). Re-posts when the verdict BUCKET flips — only
-// after the new bucket holds for 2 consecutive ticks (hysteresis: an EWMA
-// dancing around a cutoff like 0.95 must not webhook every 5s) — or when the
-// ratio drifts ≥0.15 within the same bucket. Worst case is therefore one
-// post per 2 ticks during a genuinely volatile encode, none when steady. A
-// failed post keeps the previous baseline so the next tick retries — losing
-// the single ok→struggling transition webhook would blind the player for
-// the rest of the session. Exits with the session (ctx cancel or registry
-// close); no own deadline on purpose — the session lifetime IS the bound.
-func monitorSessionHealth(ctx context.Context, hsess *engine.HLSSession, sessionID string, last *agent.SessionHealth, post func(*agent.SessionHealth, string) error) {
- const ratioDrift = 0.15
- ticker := time.NewTicker(5 * time.Second)
- defer ticker.Stop()
- pendingBucket := ""
- for {
- select {
- case <-ctx.Done():
- return
- case <-ticker.C:
- }
- if hsess.IsClosed() {
- return
- }
- st := hsess.GetTranscodeStats()
- if st.Samples == 0 {
- // Fresh ffmpeg (seek restart re-armed the warmup) — wait for the
- // new process's own measurements.
- continue
- }
- h := classifyAgentHealth(st)
- if h.Health == last.Health {
- pendingBucket = ""
- if math.Abs(h.RealtimeRatio-last.RealtimeRatio) < ratioDrift {
- continue
- }
- } else if h.Health != pendingBucket {
- // First tick in a different bucket — remember it, post only if
- // the next tick still agrees (filters threshold flapping AND any
- // residual cold-ramp dip a restart reset didn't fully absorb).
- pendingBucket = h.Health
- continue
- } else {
- pendingBucket = ""
- }
- log.Printf("[hls %s] health %s→%s (speed %.2fx→%.2fx)", agent.ShortID(sessionID), last.Health, h.Health, last.RealtimeRatio, h.RealtimeRatio)
- if post(h, "health update") != nil {
- continue // baseline unchanged → next tick retries the transition
- }
- last = h
- }
-}
-
-// Realtime-ratio cutoffs for classifyAgentHealth. This is a cross-repo contract
-// with the web bottleneck classifier (src/lib/stream/bottleneck-classifier.ts):
-// - ≥ realtimeFloor → "ok" (encoder keeps up)
-// - [strugglingFloor,..) → "marginal" (barely)
-// - < strugglingFloor → "struggling" (can't) — the web fast-path commits
-// the honest overlay + pauses on this WITHOUT waiting for a stall, so the
-// floor is intentionally conservative (the web uses a looser 0.85 only once
-// a stall has already corroborated the slowdown).
-const (
- agentRealtimeFloor = 0.95
- agentStrugglingFloor = 0.75
-)
-
-// classifyAgentHealth turns a live ffmpeg telemetry snapshot into the health
-// report the web side consumes (F3). The ×realtime speed is the load-bearing
-// signal: < 1.0 means the encode can't keep up with playback. An input-bound
-// hint (source read error) reclassifies the cause as the link, not the encoder.
-func classifyAgentHealth(st engine.TranscodeStats) *agent.SessionHealth {
- ratio := st.SpeedX
- var health, reason string
- switch {
- case st.InputBound && ratio < agentRealtimeFloor:
- health, reason = "struggling", "input_bound"
- case ratio >= agentRealtimeFloor:
- health, reason = "ok", "realtime"
- case ratio >= agentStrugglingFloor:
- health, reason = "marginal", "transcode"
- default:
- health, reason = "struggling", "transcode"
- }
- return &agent.SessionHealth{Health: health, RealtimeRatio: ratio, Reason: reason}
-}
diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go
index 4ac4d10..558fb26 100644
--- a/internal/cmd/daemon_control.go
+++ b/internal/cmd/daemon_control.go
@@ -1,7 +1,6 @@
package cmd
import (
- "errors"
"fmt"
"os"
"os/exec"
@@ -263,12 +262,9 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
- state, err := agent.LoadState()
- if err != nil {
- if errors.Is(err, agent.ErrDaemonNotRunning) {
- return err
- }
- return fmt.Errorf("read daemon state: %w", err)
+ state := agent.ReadState()
+ if state == nil {
+ return fmt.Errorf("daemon does not appear to be running (state file not found)")
}
return killPID(state.PID)
}
diff --git a/internal/cmd/download.go b/internal/cmd/download.go
index c15fd3f..bd5ceab 100644
--- a/internal/cmd/download.go
+++ b/internal/cmd/download.go
@@ -113,18 +113,17 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
MetadataTimeout: 15 * time.Minute,
StallTimeout: 10 * time.Minute,
MaxTimeout: 0, // unlimited
- // One-shot foreground download: leech then exit. Seeding only makes sense
- // for the always-on daemon (see DownloadConfig.SeedEnabled).
- SeedEnabled: false,
+ SeedEnabled: false,
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)
}
- // Local-only reporter: one-shot downloads have no server-side task, so a nil
- // client keeps terminal progress working without spamming the status API
- // (which 400s the synthetic "oneshot-" id).
- reporter := engine.NewProgressReporter(nil, 5*time.Second)
+ // Create a dummy reporter (no API reporting for one-shot)
+ reporter := engine.NewProgressReporter(
+ deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
+ 5*time.Second,
+ )
debridDl := deps.newDebridDl()
diff --git a/internal/cmd/init.go b/internal/cmd/init.go
index 113246b..9e7a8ca 100644
--- a/internal/cmd/init.go
+++ b/internal/cmd/init.go
@@ -75,19 +75,12 @@ func runInit(apiURLOverride string) error {
apiKey := cfg.Auth.APIKey
- // Resolve the agentId up front so browser-authorize can bind the minted
- // per-machine key to it.
- agentID := cfg.Agent.ID
- if agentID == "" {
- agentID = uuid.New().String()
- }
-
if apiKey == "" {
// Try browser-based auth first (like Claude Code / GitHub CLI)
fmt.Println(" Opening browser to connect your account...")
fmt.Println()
- browserKey, browserErr := browserAuth(apiURL, agentID)
+ browserKey, browserErr := browserAuth(apiURL)
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
apiKey = browserKey
green.Println(" ✓ Connected via browser")
@@ -134,6 +127,11 @@ func runInit(apiURLOverride string) error {
// Validate API key by registering with the server
fmt.Print(" Verifying API key... ")
+ agentID := cfg.Agent.ID
+ if agentID == "" {
+ agentID = uuid.New().String()
+ }
+
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
@@ -152,21 +150,9 @@ func runInit(apiURLOverride string) error {
if err != nil {
color.Red("FAILED")
fmt.Println()
- // Stored credential was revoked (machine deleted from the dashboard) —
- // drop it so a re-run mints a fresh identity.
- if agent.IsRevoked(err) {
- clearRevokedIdentity(cfg, "init")
- return nil
- }
return fmt.Errorf("API key validation failed: %w", err)
}
- // Manual-paste bootstrap: swap to the minted per-machine key, discard the
- // general key the user pasted.
- if resp.AgentKey != "" {
- apiKey = resp.AgentKey
- }
-
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()
diff --git a/internal/cmd/login.go b/internal/cmd/login.go
index f6161c2..6ecfd0a 100644
--- a/internal/cmd/login.go
+++ b/internal/cmd/login.go
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
- "log"
"os"
"runtime"
"strings"
@@ -17,20 +16,6 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
-// clearRevokedIdentity wipes the stored credential (api key + agentId) after the
-// server reports this machine's registration was revoked, so a re-run of the
-// given command mints a fresh identity instead of looping against a dead key.
-func clearRevokedIdentity(cfg config.Config, retryCmd string) {
- cfg.Auth.APIKey = ""
- cfg.Agent.ID = ""
- if err := config.Save(cfg, resolvedConfigPath()); err != nil {
- log.Printf("could not clear revoked credential: %v", err)
- }
- fmt.Println(" This machine's previous registration was removed from your account.")
- fmt.Printf(" Run `unarr %s` again to reconnect it as a new agent.\n", retryCmd)
- fmt.Println()
-}
-
func newLoginCmd() *cobra.Command {
var apiURL string
@@ -85,18 +70,11 @@ func runLogin(apiURLOverride string) error {
var apiKey string
- // Resolve the agentId up front so the browser-authorize flow can bind the
- // minted per-machine key to it.
- agentID := cfg.Agent.ID
- if agentID == "" {
- agentID = uuid.New().String()
- }
-
// Try browser-based auth first
fmt.Println(" Opening browser to connect your account...")
fmt.Println()
- browserKey, browserErr := browserAuth(apiURL, agentID)
+ browserKey, browserErr := browserAuth(apiURL)
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
apiKey = browserKey
green.Println(" ✓ Connected via browser")
@@ -142,6 +120,11 @@ func runLogin(apiURLOverride string) error {
fmt.Print(" Verifying API key... ")
+ agentID := cfg.Agent.ID
+ if agentID == "" {
+ agentID = uuid.New().String()
+ }
+
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
@@ -160,21 +143,9 @@ func runLogin(apiURLOverride string) error {
if err != nil {
color.Red("FAILED")
fmt.Println()
- // The stored credential was revoked (this machine was deleted from the
- // dashboard). Drop it so the next run mints a fresh identity.
- if agent.IsRevoked(err) {
- clearRevokedIdentity(cfg, "login")
- return nil
- }
return fmt.Errorf("API key validation failed: %w", err)
}
- // Manual-paste bootstrap: the server minted a per-machine key bound to this
- // agentId. Swap to it and discard the general key the user pasted.
- if resp.AgentKey != "" {
- apiKey = resp.AgentKey
- }
-
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()
diff --git a/internal/cmd/player_session_registry.go b/internal/cmd/player_session_registry.go
index 80fa2d1..bb3743b 100644
--- a/internal/cmd/player_session_registry.go
+++ b/internal/cmd/player_session_registry.go
@@ -41,12 +41,6 @@ func (r *playerSessionRegistryT) remove(sessionID string) {
delete(r.cancels, sessionID)
}
-func (r *playerSessionRegistryT) count() int {
- r.mu.Lock()
- defer r.mu.Unlock()
- return len(r.cancels)
-}
-
// cancelAllPlayerSessions cancels every running session. Called on daemon
// shutdown so the ffmpeg children and SSE consumers exit cleanly.
func cancelAllPlayerSessions() {
@@ -98,15 +92,5 @@ func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.Transc
VideoBitrate: cfg.Download.Transcode.VideoBitrate,
AudioBitrate: cfg.Download.Transcode.AudioBitrate,
MaxHeight: cfg.Download.Transcode.MaxHeight,
- // Tonemap HDR→SDR only when this ffmpeg build has zscale; otherwise the
- // filter would error and break playback, so HDR plays untonemapped.
- TonemapHDR: engine.FFmpegSupportsZscale(ffmpegPath),
- // libplacebo (GPU) is preferred over zscale when present — checked here so
- // the per-session arg builder can pick it for HDR sources.
- HasLibplacebo: engine.FFmpegSupportsLibplacebo(ffmpegPath),
- // scale_cuda lets an NVENC SDR downscale stay fully on the GPU. Probed
- // unconditionally (like libplacebo); fails closed to false on non-CUDA
- // hosts, where the arg builder keeps the CPU scale path anyway.
- HasScaleCuda: engine.FFmpegSupportsScaleCuda(ffmpegPath),
}
}
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/relocate_test.go b/internal/cmd/relocate_test.go
deleted file mode 100644
index 07ea7c5..0000000
--- a/internal/cmd/relocate_test.go
+++ /dev/null
@@ -1,74 +0,0 @@
-package cmd
-
-import (
- "os"
- "path/filepath"
- "runtime"
- "testing"
-)
-
-func mkfile(t *testing.T, path string) {
- t.Helper()
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
- t.Fatal(err)
- }
-}
-
-func TestRelocateUnreachable(t *testing.T) {
- root := t.TempDir()
- // A 3-segment-deep file under the current root.
- mkfile(t, filepath.Join(root, "Acme Show", "Season 01", "ep.mkv"))
- // A 2-segment-deep file (too shallow to be matched by a short tail).
- mkfile(t, filepath.Join(root, "Season 01", "lonely.mkv"))
-
- roots := []string{root}
-
- // Base-path change: an old-root path whose 3-seg tail exists under the new
- // root → relocates to the real file.
- got := relocateUnreachable("/old/base/Acme Show/Season 01/ep.mkv", roots)
- want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
- if got != want {
- t.Errorf("relocate moved file: got %q want %q", got, want)
- }
-
- // Only a 2-segment tail would match → must NOT relocate (ambiguous).
- if got := relocateUnreachable("/old/Season 01/lonely.mkv", roots); got != "" {
- t.Errorf("2-segment tail should not match, got %q", got)
- }
-
- // Nonexistent file → no relocation.
- if got := relocateUnreachable("/old/base/Acme Show/Season 01/missing.mkv", roots); got != "" {
- t.Errorf("missing file should not relocate, got %q", got)
- }
-
- // Traversal attempt: ".." segments are cleaned by filepath.Join and the
- // result is re-validated, so it can't escape.
- if got := relocateUnreachable("/old/../../../etc/passwd", roots); got != "" {
- t.Errorf("traversal should not match, got %q", got)
- }
-}
-
-func TestRelocateUnreachableSymlinkEscape(t *testing.T) {
- if runtime.GOOS == "windows" {
- t.Skip("symlink semantics differ on windows")
- }
- root := t.TempDir()
- outside := t.TempDir()
- // A real file living OUTSIDE any allowed root.
- mkfile(t, filepath.Join(outside, "sub", "secret.mkv"))
- // A symlink inside the root pointing at the outside tree.
- if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
- t.Skipf("symlink unsupported: %v", err)
- }
-
- // The lexical candidate root/link/sub/secret.mkv exists (os.Stat follows the
- // symlink), but after resolving symlinks it's outside the root → must be
- // rejected so the stream can't escape the allowed dirs.
- got := relocateUnreachable("/old/link/sub/secret.mkv", []string{root})
- if got != "" {
- t.Errorf("symlink escape must be rejected, got %q", got)
- }
-}
diff --git a/internal/cmd/resolve_playable_test.go b/internal/cmd/resolve_playable_test.go
deleted file mode 100644
index c027126..0000000
--- a/internal/cmd/resolve_playable_test.go
+++ /dev/null
@@ -1,98 +0,0 @@
-package cmd
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func TestResolvePlayableFile(t *testing.T) {
- root := t.TempDir()
- mkfile(t, filepath.Join(root, "Acme Show", "Season 01", "ep.mkv"))
- roots := []string{root}
-
- t.Run("allowed path resolves to itself", func(t *testing.T) {
- want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
- got, code, err := resolvePlayableFile(want, roots, "test")
- if err != nil {
- t.Fatalf("unexpected error (%s): %v", code, err)
- }
- if got != want {
- t.Errorf("got %q want %q", got, want)
- }
- })
-
- t.Run("old base path relocates onto current root", func(t *testing.T) {
- got, code, err := resolvePlayableFile("/old/base/Acme Show/Season 01/ep.mkv", roots, "test")
- if err != nil {
- t.Fatalf("unexpected error (%s): %v", code, err)
- }
- want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
- if got != want {
- t.Errorf("got %q want %q", got, want)
- }
- })
-
- t.Run("deleted file under old base is file_missing, never path_rejected", func(t *testing.T) {
- // The incident shape (2026-06-10): web hands a stale host path
- // (/mnt/nas/…) whose file was deleted — the docker agent can't see the
- // original path AND no tail relocates. file_missing tells the web to
- // prune the stale row; path_rejected would block that self-heal.
- _, code, err := resolvePlayableFile("/old/base/Acme Show/Season 01/gone.mkv", roots, "test")
- if err == nil {
- t.Fatal("expected error for deleted file")
- }
- if code != pathErrMissing {
- t.Errorf("code = %q, want %q", code, pathErrMissing)
- }
- })
-
- t.Run("existing file outside roots is path_rejected", func(t *testing.T) {
- outside := t.TempDir()
- // 1-segment-deep on purpose: a ≥3-segment tail could legitimately
- // relocate INTO the root if a same-named file existed there.
- mkfile(t, filepath.Join(outside, "leak.mkv"))
- _, code, err := resolvePlayableFile(filepath.Join(outside, "leak.mkv"), roots, "test")
- if err == nil {
- t.Fatal("expected error for out-of-root file")
- }
- if code != pathErrRejected {
- t.Errorf("code = %q, want %q", code, pathErrRejected)
- }
- })
-
- t.Run("missing file inside an allowed root is file_missing", func(t *testing.T) {
- _, code, err := resolvePlayableFile(filepath.Join(root, "Acme Show", "Season 01", "gone.mkv"), roots, "test")
- if err == nil {
- t.Fatal("expected error for missing file")
- }
- if code != pathErrMissing {
- t.Errorf("code = %q, want %q", code, pathErrMissing)
- }
- })
-
- t.Run("directory resolves to its video file", func(t *testing.T) {
- got, code, err := resolvePlayableFile(filepath.Join(root, "Acme Show", "Season 01"), roots, "test")
- if err != nil {
- t.Fatalf("unexpected error (%s): %v", code, err)
- }
- want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
- if got != want {
- t.Errorf("got %q want %q", got, want)
- }
- })
-
- t.Run("directory without video is no_video_file", func(t *testing.T) {
- empty := filepath.Join(root, "Empty Show")
- if err := os.MkdirAll(empty, 0o755); err != nil {
- t.Fatal(err)
- }
- _, code, err := resolvePlayableFile(empty, roots, "test")
- if err == nil {
- t.Fatal("expected error for empty directory")
- }
- if code != pathErrNoVideo {
- t.Errorf("code = %q, want %q", code, pathErrNoVideo)
- }
- })
-}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 26db370..b28ec92 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -2,14 +2,11 @@ package cmd
import (
"fmt"
- "net/http"
"os"
- "time"
"github.com/fatih/color"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
- "github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
@@ -28,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
@@ -62,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:"},
@@ -192,17 +185,6 @@ func Execute() {
}
// loadConfig loads config once (lazy initialization).
-// resolvedConfigPath returns the config file the CLI actually reads/writes,
-// honouring the global --config flag. Use this for every Save so a revocation
-// wipe or key migration lands in the right file (e.g. the dev-local agent's
-// ~/.config/unarr-dev/config.toml), not always the default path.
-func resolvedConfigPath() string {
- if cfgFile != "" {
- return cfgFile
- }
- return config.FilePath()
-}
-
func loadConfig() config.Config {
if cfgLoaded {
return appCfg
@@ -249,21 +231,6 @@ func getClient() *tc.Client {
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
- // Mirror failover for the public-API client, matching the agent control-plane
- // client's resilience: wrap the transport so search/popular/etc. rotate across
- // cfg.Auth.Mirrors on a primary takedown, using the same MirrorPool TYPE +
- // IsTransient policy the agent client uses (a fresh pool instance — the two
- // clients fail over independently). WithRetry(0) disables the go-client's own
- // retry loop so the transport owns failover exclusively (no nested
- // retry×backoff on an outage). WithTimeout(30s) is set idiomatically and gives
- // room for a couple of mirror attempts (go-client's bare default is 15s).
- pool := agent.NewMirrorPool(cfg.Auth.APIURL, cfg.Auth.Mirrors)
- opts = append(opts,
- tc.WithHTTPClient(&http.Client{Transport: agent.NewMirrorRoundTripper(pool, nil)}),
- tc.WithTimeout(30*time.Second),
- tc.WithRetry(0, 0, 0),
- )
-
apiClient = tc.NewClient(opts...)
return apiClient
}
diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go
index dbb30f8..df66a18 100644
--- a/internal/cmd/scan.go
+++ b/internal/cmd/scan.go
@@ -9,13 +9,13 @@ import (
"sort"
"strings"
"syscall"
+ "time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/library"
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
)
func newScanCmd() *cobra.Command {
@@ -39,56 +39,20 @@ to see available quality upgrades.`,
if showStatus {
return runScanStatus()
}
- cfg := loadConfig()
- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
- defer stop()
-
- // All scanned roots feed ONE sync session (single syncStartedAt +
- // final isLastBatch) so the server's stale-row cleanup sees the
- // whole cycle at once. fullCycle only without an explicit path —
- // a subtree scan must never let the server reap outside it.
if len(args) == 0 {
+ cfg := loadConfig()
paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
if len(paths) == 0 {
return fmt.Errorf("usage: unarr scan \n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'")
}
- var items []agent.LibrarySyncItem
- var caches []*library.LibraryCache
for _, p := range paths {
- cache, err := runScan(ctx, cfg, p, workers, ffprobe)
- if err != nil {
+ if err := runScan(p, workers, ffprobe, noSync); err != nil {
return err
}
- caches = append(caches, cache)
- items = append(items, library.BuildSyncItems(cache)...)
- }
- if noSync || jsonOut {
- return nil
- }
- if err := syncToServer(ctx, cfg, items, paths, true); err != nil {
- return err
- }
- if ac := scanAPIClient(cfg); ac != nil {
- for _, cache := range caches {
- detectAndSubmitSkipSegments(ctx, cfg, ac, cache)
- }
}
return nil
}
- cache, err := runScan(ctx, cfg, args[0], workers, ffprobe)
- if err != nil {
- return err
- }
- if noSync || jsonOut {
- return nil
- }
- if err := syncToServer(ctx, cfg, library.BuildSyncItems(cache), []string{args[0]}, false); err != nil {
- return err
- }
- if ac := scanAPIClient(cfg); ac != nil {
- detectAndSubmitSkipSegments(ctx, cfg, ac, cache)
- }
- return nil
+ return runScan(args[0], workers, ffprobe, noSync)
},
}
@@ -100,20 +64,18 @@ to see available quality upgrades.`,
return cmd
}
-// runScan walks one root, saves the cache and prewarms sidecars. Syncing to
-// the server is the CALLER's job (RunE) — all roots of an invocation feed one
-// sync session via syncToServer, so per-root sessions can't trick the server
-// into reaping rows of roots the session never visited.
-func runScan(ctx context.Context, cfg config.Config, dirPath string, workers int, ffprobePath string) (*library.LibraryCache, error) {
+func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error {
// Validate path
info, err := os.Stat(dirPath)
if err != nil {
- return nil, fmt.Errorf("path not found: %s", dirPath)
+ return fmt.Errorf("path not found: %s", dirPath)
}
if !info.IsDir() {
- return nil, fmt.Errorf("not a directory: %s", dirPath)
+ return fmt.Errorf("not a directory: %s", dirPath)
}
+ cfg := loadConfig()
+
// Resolve workers: flag → config → default 8
if workers == 0 {
workers = cfg.Library.Workers
@@ -130,6 +92,10 @@ func runScan(ctx context.Context, cfg config.Config, dirPath string, workers int
// Load existing cache for incremental scanning
existing, _ := library.LoadCache()
+ // Context with signal handling
+ ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+ defer stop()
+
bold := color.New(color.Bold)
bold.Printf("\n Scanning %s...\n\n", dirPath)
@@ -147,14 +113,14 @@ func runScan(ctx context.Context, cfg config.Config, dirPath string, workers int
},
})
if err != nil {
- return nil, fmt.Errorf("scan failed: %w", err)
+ return fmt.Errorf("scan failed: %w", err)
}
fmt.Fprintf(os.Stderr, "\r\033[K") // clear progress line
// Save cache
if err := library.SaveCache(cache); err != nil {
- return nil, fmt.Errorf("save cache: %w", err)
+ return fmt.Errorf("save cache: %w", err)
}
// Remember scan path in config
@@ -166,57 +132,22 @@ func runScan(ctx context.Context, cfg config.Config, dirPath string, workers int
// Print summary
printScanSummary(cache)
- // JSON output mode — emit the cache and skip the prewarm (the caller skips
- // the sync via the same flag).
+ // JSON output mode
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
- return cache, enc.Encode(cache)
+ return enc.Encode(cache)
}
- // Pre-extract sidecars (text subs → WebVTT, panel frames → JPEG) into a hidden
- // ".unarr" dir so playback gets instant subtitles/thumbnails and huge remuxes
- // never hit the on-demand timeout. Best-effort + Ctrl-C interruptible (the scan
- // itself is already saved).
- if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails || cfg.Library.Trickplay.Enabled {
- if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil {
- fmt.Fprintf(os.Stderr, " Pre-extracting subtitles + thumbnails to cache… (Ctrl-C to skip)\n")
- library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{
- FFmpegPath: ff,
- CacheSubtitles: cfg.Library.CacheSubtitles,
- CacheThumbnails: cfg.Library.CacheThumbnails,
- Workers: 2,
- Trickplay: cfg.Library.Trickplay.Enabled,
- TrickplayIntervalSec: cfg.Library.Trickplay.IntervalSeconds(),
- TrickplayWidth: cfg.Library.Trickplay.Width,
- MaxLoadRatio: cfg.Library.PrewarmMaxLoadRatio,
- })
- } else {
- fmt.Fprintf(os.Stderr, " Skipping sidecar prewarm: ffmpeg unavailable: %v\n", err)
- }
+ // Sync to server
+ if !noSync {
+ return syncToServer(ctx, cfg, cache)
}
- return cache, nil
+ return nil
}
-// scanAPIClient builds the agent API client for post-scan submissions, using
-// the same key resolution as syncToServer. Nil when no key is configured.
-func scanAPIClient(cfg config.Config) *agent.Client {
- apiKey := apiKeyFlag
- if apiKey == "" {
- apiKey = cfg.Auth.APIKey
- }
- if apiKey == "" {
- return nil
- }
- return agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
-}
-
-// syncToServer uploads the scanned items of THIS invocation as one sync
-// session. roots lists every root the invocation scanned; fullCycle marks a
-// no-args run that covered all configured roots (the server may then reap
-// stale rows regardless of prefix — see LibrarySyncRequest.FullCycle).
-func syncToServer(ctx context.Context, cfg config.Config, items []agent.LibrarySyncItem, roots []string, fullCycle bool) error {
+func syncToServer(ctx context.Context, cfg config.Config, cache *library.LibraryCache) error {
apiKey := apiKeyFlag
if apiKey == "" {
apiKey = cfg.Auth.APIKey
@@ -228,28 +159,49 @@ func syncToServer(ctx context.Context, cfg config.Config, items []agent.LibraryS
ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
+ items := library.BuildSyncItems(cache)
+
if len(items) == 0 {
color.Yellow("\n No valid items to sync.")
return nil
}
- res, err := library.SyncBatches(ctx, ac, items, library.SyncOptions{
- AgentID: cfg.Agent.ID,
- ScanPath: roots[0],
- ScanRoots: roots,
- FullCycle: fullCycle,
- OnProgress: func(sent, total int) {
- fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", sent, total)
- },
- })
- if err != nil {
- return fmt.Errorf("sync failed: %w", err)
+ // Send in batches of 100
+ const batchSize = 100
+ totalSynced := 0
+ totalMatched := 0
+ totalRemoved := 0
+ syncStartedAt := time.Now().UTC().Format(time.RFC3339)
+
+ for i := 0; i < len(items); i += batchSize {
+ end := i + batchSize
+ if end > len(items) {
+ end = len(items)
+ }
+ batch := items[i:end]
+ isLast := end >= len(items)
+
+ fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", end, len(items))
+
+ resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
+ Items: batch,
+ ScanPath: cache.Path,
+ IsLastBatch: isLast,
+ SyncStartedAt: syncStartedAt,
+ })
+ if err != nil {
+ return fmt.Errorf("sync failed: %w", err)
+ }
+
+ totalSynced += resp.Synced
+ totalMatched += resp.Matched
+ totalRemoved += resp.Removed
}
fmt.Fprintf(os.Stderr, "\r\033[K")
green := color.New(color.FgGreen)
- green.Printf("\n ✓ Synced %d items (%d matched, %d removed)\n", res.Synced, res.Matched, res.Removed)
+ green.Printf("\n ✓ Synced %d items (%d matched, %d removed)\n", totalSynced, totalMatched, totalRemoved)
apiURL := strings.TrimSuffix(cfg.Auth.APIURL, "/")
fmt.Printf(" → View upgrades at %s/library\n\n", apiURL)
@@ -289,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/skipdetect.go b/internal/cmd/skipdetect.go
deleted file mode 100644
index 4c76166..0000000
--- a/internal/cmd/skipdetect.go
+++ /dev/null
@@ -1,91 +0,0 @@
-package cmd
-
-import (
- "context"
- "log"
-
- "github.com/torrentclaw/unarr/internal/agent"
- "github.com/torrentclaw/unarr/internal/config"
- "github.com/torrentclaw/unarr/internal/library"
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
-)
-
-// detectAndSubmitSkipSegments runs intro/credits detection over a scanned
-// cache and uploads the results. Called AFTER the library sync (the server
-// resolves file paths against the just-synced library_item rows). Best-effort:
-// every failure logs and returns — a scan must never fail because of this.
-func detectAndSubmitSkipSegments(ctx context.Context, cfg config.Config, ac *agent.Client, cache *library.LibraryCache) {
- if !cfg.Library.SkipDetect || cache == nil || ctx.Err() != nil {
- return
- }
- ffmpegPath, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
- if err != nil {
- log.Printf("[skipdetect] skipped: ffmpeg unavailable: %v", err)
- return
- }
- fpcalcPath, err := mediainfo.ResolveFpcalc()
- if err != nil {
- // Movies-only still works (black frames need just ffmpeg).
- log.Printf("[skipdetect] fpcalc unavailable (episode detection off): %v", err)
- fpcalcPath = ""
- }
-
- // Two phases so fast results don't wait on slow ones: episode fingerprinting
- // is seconds per season (and often pure cache), while movie black-frame
- // scans grind through 4K tails over the NAS — episodes submit first.
- episodes := library.DetectSkipSegments(ctx, cache, library.SkipDetectOptions{
- FFmpegPath: ffmpegPath,
- FpcalcPath: fpcalcPath,
- Workers: 2,
- })
- submitSkipSegments(ctx, cfg, ac, episodes)
-
- movies := library.DetectSkipSegments(ctx, cache, library.SkipDetectOptions{
- FFmpegPath: ffmpegPath,
- Workers: 2,
- Movies: true,
- })
- submitSkipSegments(ctx, cfg, ac, movies)
-}
-
-func submitSkipSegments(ctx context.Context, cfg config.Config, ac *agent.Client, detections []library.SkipDetection) {
- if len(detections) == 0 || ac == nil || ctx.Err() != nil {
- return
- }
-
- items := make([]agent.SkipSegmentItem, 0, len(detections))
- for _, d := range detections {
- segs := make([]agent.SkipSegmentRange, 0, len(d.Segments))
- for _, s := range d.Segments {
- segs = append(segs, agent.SkipSegmentRange{Category: s.Category, StartSec: s.StartSec, EndSec: s.EndSec})
- }
- items = append(items, agent.SkipSegmentItem{
- FilePath: d.Item.FilePath,
- Title: d.Item.Title,
- Season: d.Item.Season,
- Episode: d.Item.Episode,
- DurationSec: d.DurationSec,
- Segments: segs,
- })
- }
-
- const batchSize = 200
- stored, unmatched := 0, 0
- for start := 0; start < len(items); start += batchSize {
- end := start + batchSize
- if end > len(items) {
- end = len(items)
- }
- res, err := ac.SubmitSkipSegments(ctx, agent.SkipSegmentsRequest{
- AgentID: cfg.Agent.ID,
- Items: items[start:end],
- })
- if err != nil {
- log.Printf("[skipdetect] submit failed: %v", err)
- return
- }
- stored += res.Stored
- unmatched += res.Unmatched
- }
- log.Printf("[skipdetect] submitted %d file(s): %d segment(s) stored, %d unmatched", len(items), stored, unmatched)
-}
diff --git a/internal/cmd/stream_handler.go b/internal/cmd/stream_handler.go
index 4bf2ada..fa61220 100644
--- a/internal/cmd/stream_handler.go
+++ b/internal/cmd/stream_handler.go
@@ -87,17 +87,10 @@ func cancelStreamTask(taskID string) {
// handleStreamTask manages a streaming task lifecycle for active torrent downloads.
// It creates a StreamEngine, buffers, sets the file on the persistent server,
// and reports progress until the task is cancelled or the download completes.
-func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config, agentClient *agent.Client, srv *engine.StreamServer, onStateChange func()) {
+func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config, agentClient *agent.Client, srv *engine.StreamServer) {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
- // NOTE: we deliberately do NOT cancel prior stream goroutines here. The
- // persistent StreamServer is last-writer-wins (SetFile replaces the file;
- // the deferred ClearFile is guarded by CurrentTaskID), so a displaced prior
- // goroutine simply parks on its own ctx until the 30m idle guard reaps it —
- // cheap. Cancelling them at entry would abort an in-flight debrid HEAD of a
- // concurrently-starting task (size resolution), failing that stream.
-
// Register for web-initiated cancellation
streamRegistry.mu.Lock()
streamRegistry.cancels[at.ID] = cancel
@@ -113,55 +106,10 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
}()
task := engine.NewTaskFromAgent(at)
- // Event-driven uplink: stream tasks transition outside the Manager (which
- // wires this for downloads), so set it here too — resolving/downloading/
- // completed/failed get pushed to the server immediately.
- task.SetOnChange(onStateChange)
task.ResolvedMethod = engine.MethodTorrent
reporter.Track(task)
defer reporter.ReportFinal(context.Background(), task)
- // Debrid passthrough: when the web resolved a direct HTTPS link (the torrent
- // is cached on the user's debrid + preferredMethod=debrid), stream FROM that
- // link instead of joining the P2P swarm — served over the SAME /stream
- // endpoint, so VLC / external players consume it identically (and far
- // faster). No HLS transcode here: external players handle any container.
- // Falls through to the P2P StreamEngine below when there is no direct URL.
- if at.DirectURL != "" {
- task.ResolvedMethod = engine.MethodDebrid
- task.Transition(engine.StatusResolving)
- bctx, bcancel := context.WithTimeout(ctx, 15*time.Second)
- // fallbackSize 0 → provider derives size from a HEAD; refresh nil → no
- // task-level link-refresh endpoint exists (the web re-resolves stale
- // debrid URLs at the next claim). A mid-stream expiry just ends the
- // stream and the user re-opens it.
- provider, perr := engine.NewDebridFileProvider(bctx, at.DirectURL, at.DirectFileName, 0, nil)
- bcancel()
- if perr != nil {
- task.ErrorMessage = "debrid stream provider: " + perr.Error()
- task.Transition(engine.StatusFailed)
- return
- }
- srv.SetFile(provider, at.ID)
- task.FileName = provider.FileName()
- task.TotalBytes = provider.FileSize()
- task.SetStreamURL(srv.URLsJSON()) // mutex-safe: the reporter reads it via GetStreamURL
- log.Printf("[%s] stream (debrid): %s (%s) url: %s", at.ID[:8], provider.FileName(), ui.FormatBytes(provider.FileSize()), srv.URL())
-
- if agentClient != nil {
- watchReporter := engine.NewWatchReporter(agentClient, srv, at.ID)
- go watchReporter.Run(ctx)
- }
-
- // Debrid serves a complete remote file — there is no download to track,
- // so mark it complete immediately (the UI shows "ready"). The persistent
- // server keeps serving until the idle guard reaps it (30m), same as P2P.
- task.Transition(engine.StatusCompleted)
- <-ctx.Done()
- log.Printf("[%s] stream (debrid) stopped", at.ID[:8])
- return
- }
-
// 1. Create StreamEngine
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: cfg.Download.Dir,
diff --git a/internal/cmd/version.go b/internal/cmd/version.go
index 4274fcb..32f1ce3 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 = "1.1.3-beta"
+var Version = "0.9.6"
diff --git a/internal/config/config.go b/internal/config/config.go
index 93f82cc..12573b4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -7,7 +7,6 @@ import (
"runtime"
"strconv"
"strings"
- "time"
"github.com/BurntSushi/toml"
)
@@ -37,54 +36,24 @@ type AuthConfig struct {
type AgentConfig struct {
ID string `toml:"id"`
Name string `toml:"name"`
- // Hash is a stable high-entropy label (hex) for the per-agent direct-TLS
- // feature. Distinct from ID (a UUID that could be guessed/enumerated): the
- // cert broker issues *..agent.unarr.app and the web encodes the agent's
- // IP into a hostname under that wildcard. Generated + persisted on first run.
- Hash string `toml:"agent_hash,omitempty"`
}
type DownloadConfig struct {
- Dir string `toml:"dir"`
- PreferredMethod string `toml:"preferred_method"`
- PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
- MaxConcurrent int `toml:"max_concurrent"`
- MinFreeDiskMB int `toml:"min_free_disk_mb"` // refuse a download if it would leave less than this free (reserve to keep the FS healthy); default 2048, 0 = disable
- MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
- MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
- // Seeding lifecycle (BitTorrent only). Off by default — the daemon leeches
- // then drops the torrent. Enable to keep uploading after a download finishes;
- // seeding stops at whichever target is hit first, or never if both are unset.
- SeedEnabled bool `toml:"seed_enabled"` // keep uploading after completion (default: false)
- SeedRatio float64 `toml:"seed_ratio"` // stop once uploaded/size reaches this ratio (0 = no ratio target)
- SeedTime string `toml:"seed_time"` // stop after this long since completion, e.g. "24h" (0/"" = no time target)
- MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
- StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
- ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
- StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
- HTTPSStreamPort int `toml:"https_stream_port"` // HTTPS stream listener for direct valid-cert playback (default: 11819, 0 = disabled). Only serves once a certificate is present (agent-TLS feature).
- EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in)
- // RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a
- // signed, short-lived token embedded in the URLs the agent reports. Default
- // true (secure by default); loopback callers (local mpv/vlc) are always exempt.
- // Set false only to debug a player that can't carry the token.
- RequireStreamToken bool `toml:"require_stream_token"`
- 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
+ Dir string `toml:"dir"`
+ PreferredMethod string `toml:"preferred_method"`
+ PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
+ MaxConcurrent int `toml:"max_concurrent"`
+ MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
+ MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
+ MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
+ StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
+ ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
+ StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
+ EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
+ CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
+ Transcode TranscodeConfig `toml:"transcode"`
+ VPN VPNConfig `toml:"vpn"`
+ Funnel FunnelConfig `toml:"funnel"`
}
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
@@ -115,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)
@@ -150,32 +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"`
- // Downlink selects the server→agent realtime transport. "auto" (default)
- // uses an SSE push connection with the long-poll wake as a buffering-tolerant
- // fallback; "sse" forces SSE only (no fallback); "poll" forces the pre-0.14
- // long-poll wake only. Empty = "auto". Available since unarr 0.14.0.
- Downlink string `toml:"downlink"`
}
-// 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"`
}
@@ -193,67 +120,8 @@ type LibraryConfig struct {
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
- ScanInterval string `toml:"scan_interval"` // e.g. "1h", "6h", "24h" (default "1h", like Plex/Jellyfin periodic scans)
+ ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
-
- // Sidecar caching: extract text subtitles (WebVTT) and thumbnail frames once
- // during the library scan and store them in a hidden ".unarr" dir next to the
- // media file, so the stream handlers serve them instantly instead of running
- // ffmpeg per request (and so huge remuxes don't hit the on-demand HTTP
- // timeout). Both default true; disable to save the disk/CPU of pre-extraction.
- CacheSubtitles bool `toml:"cache_subtitles"` // default true
- CacheThumbnails bool `toml:"cache_thumbnails"` // default true
-
- // Skip-segment detection: after each scan, find intro/credits ranges by
- // comparing chromaprint audio fingerprints between episodes of a season
- // (plus black-frame credits for movies) and submit them to the web so the
- // player can offer "Skip intro" / "Skip credits". Cached per file; only
- // new files do work. Default true.
- SkipDetect bool `toml:"skip_detect"`
-
- // Trickplay: at scan time, build ONE montage JPEG of frames sampled every
- // Interval seconds (+ a JSON manifest), cached in .unarr next to the media.
- // The web scrubber shows tiles from it — no live ffmpeg during playback, so
- // no contention with the active stream (the cause of broken seekbar previews)
- // — and the file panel picks a few positions from the same grid.
- Trickplay TrickplayConfig `toml:"trickplay"`
-
- // PrewarmMaxLoadRatio gates the heavy trickplay decode on system load: a sprite
- // job only starts while the 1-min load average is ≤ this × NumCPU, so scan-time
- // generation never saturates the machine or the NAS. Default 0.7; 0 falls back
- // to the default. Linux-only (no load reading elsewhere → unthrottled).
- PrewarmMaxLoadRatio float64 `toml:"prewarm_max_load_ratio"`
-
- // On-demand / automatic subtitle fetching from the web (Wyzie aggregator,
- // PRO). The web can always push a hot request (library/player button); this
- // section only controls SCAN-TIME auto-fetch, which is OFF by default.
- Subtitles SubtitlesConfig `toml:"subtitles"`
-}
-
-// SubtitlesConfig controls scan-time subtitle auto-fetch.
-type SubtitlesConfig struct {
- // AutoFetch: during a library scan, fetch missing subtitles for the preferred
- // languages and write them as sidecars. Default false (opt-in).
- AutoFetch bool `toml:"auto_fetch"`
- // Languages: preferred subtitle languages (ISO 639-1) to ensure exist, in
- // priority order, e.g. ["es", "en"]. Empty → auto-fetch does nothing.
- Languages []string `toml:"languages"`
-}
-
-// TrickplayConfig controls scan-time trickplay sprite generation.
-type TrickplayConfig struct {
- Enabled bool `toml:"enabled"` // generate the sprite during scan (default true)
- Interval string `toml:"interval"` // one frame per Interval, e.g. "10s" (default)
- Width int `toml:"width"` // tile width px; height keeps aspect (default 240)
-}
-
-// IntervalSeconds parses Interval ("10s") to seconds, falling back to 10 on an
-// empty/invalid value so a typo can't silently disable the sprite.
-func (t TrickplayConfig) IntervalSeconds() float64 {
- if d, err := time.ParseDuration(strings.TrimSpace(t.Interval)); err == nil && d > 0 {
- return d.Seconds()
- }
- return 10
}
// Default returns a Config with sensible defaults. Used both for fresh
@@ -271,19 +139,13 @@ func Default() Config {
},
},
Download: DownloadConfig{
- PreferredMethod: "auto",
- MaxConcurrent: 3,
- MinFreeDiskMB: 2048, // 2 GiB reserve
- StreamPort: 11818,
- HTTPSStreamPort: 11819,
- RequireStreamToken: true, // secure by default; loopback exempt
+ PreferredMethod: "auto",
+ MaxConcurrent: 3,
+ StreamPort: 11818,
Transcode: TranscodeConfig{
- Enabled: true,
- HWAccel: "auto",
- // Empty preset → engine.ResolveEncoderProfile picks the
- // latency-biased default ("superfast" on libx264). Override
- // in config.toml when quality > first-start latency matters.
- Preset: "",
+ Enabled: true,
+ HWAccel: "auto",
+ Preset: "veryfast",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
@@ -294,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,
},
@@ -320,18 +169,9 @@ func Default() Config {
Locale: "en",
},
Library: LibraryConfig{
- AutoScan: true,
- ScanInterval: "1h",
- Workers: 8,
- CacheSubtitles: true,
- CacheThumbnails: true,
- SkipDetect: true,
- Trickplay: TrickplayConfig{
- Enabled: true,
- Interval: "10s",
- Width: 240,
- },
- PrewarmMaxLoadRatio: 0.7,
+ AutoScan: true,
+ ScanInterval: "24h",
+ Workers: 8,
},
}
}
@@ -382,49 +222,13 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("downloads", "max_concurrent") {
cfg.Download.MaxConcurrent = 3
}
- if !meta.IsDefined("downloads", "min_free_disk_mb") {
- cfg.Download.MinFreeDiskMB = 2048 // 2 GiB reserve so a download never fills the FS to 0
- }
if !meta.IsDefined("downloads", "stream_port") {
cfg.Download.StreamPort = 11818
}
- if !meta.IsDefined("downloads", "https_stream_port") {
- cfg.Download.HTTPSStreamPort = 11819
- }
if !meta.IsDefined("general", "country") {
cfg.General.Country = "US"
}
- // Sidecar caching defaults ON for existing configs that predate these keys —
- // it only adds small hidden files next to media and makes subs/thumbnails
- // instant. Power users can set them false explicitly to opt out.
- if !meta.IsDefined("library", "cache_subtitles") {
- cfg.Library.CacheSubtitles = true
- }
- if !meta.IsDefined("library", "cache_thumbnails") {
- cfg.Library.CacheThumbnails = true
- }
- if !meta.IsDefined("library", "skip_detect") {
- cfg.Library.SkipDetect = true
- }
- // Trickplay defaults ON for configs predating these keys (small sidecar JPEG;
- // makes the scrubber instant + contention-free). Explicit `enabled = false`
- // is respected via meta.IsDefined.
- if !meta.IsDefined("library", "trickplay", "enabled") {
- cfg.Library.Trickplay.Enabled = true
- }
- if !meta.IsDefined("library", "trickplay", "interval") {
- cfg.Library.Trickplay.Interval = "10s"
- }
- if !meta.IsDefined("library", "trickplay", "width") {
- cfg.Library.Trickplay.Width = 240
- }
- // Load-gate defaults ON for configs predating the key, so an old install can't
- // saturate the box with scan-time sprite generation.
- if !meta.IsDefined("library", "prewarm_max_load_ratio") {
- cfg.Library.PrewarmMaxLoadRatio = 0.7
- }
-
if !meta.IsDefined("downloads", "transcode", "enabled") {
cfg.Download.Transcode.Enabled = true
}
@@ -432,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 2ee6e23..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)
@@ -246,55 +243,6 @@ enabled = false
}
}
-func TestLoadSeedingDefaultsOff(t *testing.T) {
- tmp := t.TempDir()
- path := filepath.Join(tmp, "config.toml")
-
- // No [downloads] seeding keys — seeding must stay off by default.
- os.WriteFile(path, []byte(`[auth]
-api_key = "tc_x"
-`), 0o644)
-
- cfg, err := Load(path)
- if err != nil {
- t.Fatalf("Load failed: %v", err)
- }
- if cfg.Download.SeedEnabled {
- t.Error("SeedEnabled should default to false")
- }
- if cfg.Download.SeedRatio != 0 {
- t.Errorf("SeedRatio = %v, want 0", cfg.Download.SeedRatio)
- }
- if cfg.Download.SeedTime != "" {
- t.Errorf("SeedTime = %q, want empty", cfg.Download.SeedTime)
- }
-}
-
-func TestLoadSeedingExplicit(t *testing.T) {
- tmp := t.TempDir()
- path := filepath.Join(tmp, "config.toml")
-
- os.WriteFile(path, []byte(`[downloads]
-seed_enabled = true
-seed_ratio = 2.0
-seed_time = "24h"
-`), 0o644)
-
- cfg, err := Load(path)
- if err != nil {
- t.Fatalf("Load failed: %v", err)
- }
- if !cfg.Download.SeedEnabled {
- t.Error("SeedEnabled = false, want true")
- }
- if cfg.Download.SeedRatio != 2.0 {
- t.Errorf("SeedRatio = %v, want 2.0", cfg.Download.SeedRatio)
- }
- if cfg.Download.SeedTime != "24h" {
- t.Errorf("SeedTime = %q, want 24h", cfg.Download.SeedTime)
- }
-}
-
func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
diff --git a/internal/config/paths.go b/internal/config/paths.go
index 3f9fdde..19f06b9 100644
--- a/internal/config/paths.go
+++ b/internal/config/paths.go
@@ -38,15 +38,6 @@ func FilePath() string {
return filepath.Join(Dir(), "config.toml")
}
-// LockPath returns the daemon single-instance lock file, alongside config.toml.
-// Scoped to the config dir so a separate UNARR_CONFIG_DIR (e.g. a dev agent)
-// gets its own lock and can run concurrently; two daemons sharing one config
-// dir cannot — that's the case that causes cross-talk (same agentId/hash/secret
-// racing each other).
-func LockPath() string {
- return filepath.Join(Dir(), "unarr.lock")
-}
-
// DataDir returns the data directory for logs, cache, etc.
// - Linux: ~/.local/share/unarr
// - macOS: ~/Library/Application Support/unarr
diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go
index 06e1905..ed93044 100644
--- a/internal/config/paths_test.go
+++ b/internal/config/paths_test.go
@@ -23,14 +23,6 @@ func TestFilePath(t *testing.T) {
}
}
-func TestLockPath(t *testing.T) {
- t.Setenv("UNARR_CONFIG_DIR", "/custom/path")
- path := LockPath()
- if path != "/custom/path/unarr.lock" {
- t.Errorf("LockPath() = %q, want /custom/path/unarr.lock", path)
- }
-}
-
func TestDataDir(t *testing.T) {
dir := DataDir()
if dir == "" {
diff --git a/internal/engine/debrid.go b/internal/engine/debrid.go
index 49fe8e6..fce60dd 100644
--- a/internal/engine/debrid.go
+++ b/internal/engine/debrid.go
@@ -27,8 +27,6 @@ var httpClient = &http.Client{
type DebridDownloader struct {
activeMu sync.Mutex
active map[string]context.CancelFunc
-
- minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
}
// NewDebridDownloader creates a debrid downloader.
@@ -38,11 +36,6 @@ func NewDebridDownloader() *DebridDownloader {
}
}
-// SetMinFreeBytes sets the free-space reserve enforced before a download starts.
-// Call once at construction; 0 disables the reserve (the size-vs-free check still
-// runs). See CheckDiskSpace.
-func (d *DebridDownloader) SetMinFreeBytes(n int64) { d.minFreeBytes = n }
-
func (d *DebridDownloader) Method() DownloadMethod { return MethodDebrid }
// Available returns true if the task has a direct HTTPS URL from the server.
@@ -174,12 +167,6 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
log.Printf("[%s] starting debrid download: %s", agent.ShortID(task.ID), fileName)
}
- // Pre-flight disk-space guard on the bytes still to write (resume subtracts
- // what's already on disk). Best-effort; ENOSPC stays the backstop.
- if err := CheckDiskSpace(outputDir, totalBytes-startOffset, d.minFreeBytes); err != nil {
- return nil, err
- }
-
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return nil, fmt.Errorf("create directory: %w", err)
}
diff --git a/internal/engine/diskspace.go b/internal/engine/diskspace.go
deleted file mode 100644
index 7faf792..0000000
--- a/internal/engine/diskspace.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package engine
-
-import (
- "errors"
- "fmt"
- "log"
-
- "github.com/torrentclaw/unarr/internal/agent"
-)
-
-// InsufficientDiskError is returned by CheckDiskSpace when a download's expected
-// size (plus a reserve that keeps the filesystem healthy) won't fit in the free
-// space of its target directory. The manager treats it as terminal — it does NOT
-// fall back to another source (a different source would fill the same disk) and
-// surfaces the message to the web as the task's error.
-type InsufficientDiskError struct {
- Dir string
- Need int64 // bytes the download still needs to write
- Free int64 // bytes currently free on Dir's filesystem
- Reserve int64 // bytes to keep free after the download
-}
-
-func (e *InsufficientDiskError) Error() string {
- return fmt.Sprintf(
- "insufficient disk space in %s: need %s + %s reserve, only %s free",
- e.Dir, formatBytes(e.Need), formatBytes(e.Reserve), formatBytes(e.Free),
- )
-}
-
-// IsInsufficientDisk reports whether err is (or wraps) an InsufficientDiskError.
-func IsInsufficientDisk(err error) bool {
- var d *InsufficientDiskError
- return errors.As(err, &d)
-}
-
-// CheckDiskSpace fails fast when dir's filesystem can't hold needBytes while
-// keeping reserveBytes free. It's the pre-flight guard so a download never fills
-// the disk to 0 mid-write (which corrupts the partial file and can wedge the OS).
-//
-// Best-effort by design: a non-positive needBytes (size unknown) or a failure to
-// stat the filesystem returns nil rather than block a download on a guard we
-// can't evaluate — the OS-level ENOSPC stays the backstop.
-func CheckDiskSpace(dir string, needBytes, reserveBytes int64) error {
- if needBytes <= 0 {
- return nil // size unknown — nothing to check against
- }
- free, _, err := agent.DiskInfo(dir)
- if err != nil {
- log.Printf("[disk] free-space pre-flight skipped for %q: stat error: %v", dir, err)
- return nil
- }
- if free <= 0 {
- // Distinct from a stat error: DiskInfo succeeded but reports no free
- // space. Don't block on a value we can't trust (0/negative) — log it so a
- // genuinely-full disk is visible rather than masked as a generic skip.
- log.Printf("[disk] free-space pre-flight skipped for %q: DiskInfo reported non-positive free (%d)", dir, free)
- return nil
- }
- if free-needBytes < reserveBytes {
- return &InsufficientDiskError{Dir: dir, Need: needBytes, Free: free, Reserve: reserveBytes}
- }
- return nil
-}
diff --git a/internal/engine/diskspace_test.go b/internal/engine/diskspace_test.go
deleted file mode 100644
index a24d5be..0000000
--- a/internal/engine/diskspace_test.go
+++ /dev/null
@@ -1,56 +0,0 @@
-package engine
-
-import (
- "path/filepath"
- "strings"
- "testing"
-)
-
-func TestCheckDiskSpace_Enough(t *testing.T) {
- // A tiny need in a real temp dir (huge free space) → nil.
- if err := CheckDiskSpace(t.TempDir(), 1024, 0); err != nil {
- t.Errorf("expected nil for a 1 KiB need, got %v", err)
- }
-}
-
-func TestCheckDiskSpace_Insufficient(t *testing.T) {
- // Need more than any real disk has → InsufficientDiskError.
- err := CheckDiskSpace(t.TempDir(), 1<<62, 0)
- if err == nil {
- t.Fatal("expected an error for an impossibly large need")
- }
- if !IsInsufficientDisk(err) {
- t.Errorf("IsInsufficientDisk = false, want true (err=%v)", err)
- }
- if !strings.Contains(err.Error(), "insufficient disk space") {
- t.Errorf("error message = %q, want it to mention insufficient disk space", err.Error())
- }
-}
-
-func TestCheckDiskSpace_ReserveTriggers(t *testing.T) {
- // Tiny need but an impossibly large reserve → free-need < reserve → error.
- err := CheckDiskSpace(t.TempDir(), 1024, 1<<62)
- if !IsInsufficientDisk(err) {
- t.Errorf("expected insufficient when reserve exceeds free space, got %v", err)
- }
-}
-
-func TestCheckDiskSpace_UnknownSize(t *testing.T) {
- // need <= 0 means the size is unknown — the check must be skipped, even with
- // an enormous reserve.
- if err := CheckDiskSpace(t.TempDir(), 0, 1<<62); err != nil {
- t.Errorf("need=0 must skip the check, got %v", err)
- }
- if err := CheckDiskSpace(t.TempDir(), -5, 1<<62); err != nil {
- t.Errorf("negative need must skip the check, got %v", err)
- }
-}
-
-func TestCheckDiskSpace_BadDirIsBestEffort(t *testing.T) {
- // An unstat-able path → DiskInfo errors → best-effort nil (never block a
- // download on a guard we can't evaluate; ENOSPC stays the backstop).
- bad := filepath.Join(t.TempDir(), "does", "not", "exist")
- if err := CheckDiskSpace(bad, 1<<40, 0); err != nil {
- t.Errorf("unstat-able dir must skip the check, got %v", err)
- }
-}
diff --git a/internal/engine/encode_benchmark.go b/internal/engine/encode_benchmark.go
deleted file mode 100644
index 6fd3740..0000000
--- a/internal/engine/encode_benchmark.go
+++ /dev/null
@@ -1,120 +0,0 @@
-package engine
-
-import (
- "context"
- "log"
- "os/exec"
- "strconv"
- "time"
-)
-
-// benchmarkRung is a candidate transcode-height ceiling plus the 16:9 frame
-// size used to measure whether a software encoder sustains it.
-type benchmarkRung struct {
- height int
- width int
-}
-
-// softwareBenchmarkRungs are tested high→low. The frame sizes match the real
-// streaming output tiers; the H.264 level / macroblock math in hls.go is
-// independent of what we measure here.
-var softwareBenchmarkRungs = []benchmarkRung{
- {height: 1080, width: 1920},
- {height: 720, width: 1280},
- {height: 480, width: 854},
-}
-
-// realtimeMarginSoftware is how much faster than realtime a synthetic encode
-// must run before we call a rung "sustainable". 2.0× (not 1.5×) because the
-// benchmark measures ONLY the encode of a low-entropy synthetic source and
-// must cover two costs it never sees: (a) decoding the real source — software
-// HEVC / 10-bit decode can rival the encode cost on its own — and (b) real
-// content (film grain, motion) being far busier than testsrc2 for x264's
-// rate-control + motion estimation. Erring high routes a borderline box's
-// oversized sources to an external player (which works) instead of a
-// stuttering transcode (which is the failure we're preventing).
-const realtimeMarginSoftware = 2.0
-
-// benchmarkClipSeconds is the synthetic clip length. Short enough that a
-// capable host finishes the 1080p rung in well under a second, long enough to
-// average out process spin-up.
-const benchmarkClipSeconds = 3
-
-// BenchmarkMaxTranscodeHeight returns the largest output height this host can
-// software-transcode in real time, one of {1080,720,480}. Hardware encoders
-// return 2160 WITHOUT benchmarking — NVENC/QSV/VAAPI/VideoToolbox all sustain
-// 4K and a probe would only add startup latency.
-//
-// The point is the weak end. A low-power NAS or an old CPU can be
-// ffmpeg-capable yet unable to keep up with a 1080p software encode, so the
-// historical static 1080 ceiling makes the web side attempt a transcode that
-// stutters. Measuring real throughput lets decideStreamPlan route oversized
-// sources to an external player instead. Floors at 480: a box that can't
-// sustain even that is barely functional, and 480-or-smaller sources transcode
-// cheaply regardless — anything larger is already gated out by the 480 ceiling.
-func BenchmarkMaxTranscodeHeight(ctx context.Context, ffmpegPath string, hw HWAccel) int {
- if hw != HWAccelNone {
- return 2160
- }
- if ffmpegPath == "" {
- return 1080 // no benchmark possible; keep the historical default
- }
- measuredAny := false
- for _, rung := range softwareBenchmarkRungs {
- factor, ok := measureEncodeRealtimeFactor(ctx, ffmpegPath, rung)
- if !ok {
- // Probe couldn't run (timeout / exec error) — try a lighter rung
- // rather than treat the failure as a measured "fast enough".
- log.Printf("[transcode] encode benchmark: %dp probe failed — trying lower", rung.height)
- continue
- }
- measuredAny = true
- if factor >= realtimeMarginSoftware {
- log.Printf("[transcode] encode benchmark: software ceiling %dp (%.1f× realtime)", rung.height, factor)
- return rung.height
- }
- log.Printf("[transcode] encode benchmark: %dp only %.1f× realtime (<%.1f×) — trying lower", rung.height, factor, realtimeMarginSoftware)
- }
- if !measuredAny {
- // No rung produced a measurement at all — the benchmark infrastructure
- // failed (missing lavfi/testsrc2, ffmpeg wedged), NOT a slow host. Don't
- // punish a possibly-capable box by flooring at 480; keep the historical
- // default so behaviour is no worse than before the benchmark existed.
- log.Printf("[transcode] encode benchmark: no rung could be measured (lavfi/ffmpeg issue) — keeping default 1080 ceiling")
- return 1080
- }
- log.Printf("[transcode] encode benchmark: host can't sustain 480p software encode — flooring ceiling at 480 (oversized sources route to external)")
- return 480
-}
-
-// measureEncodeRealtimeFactor encodes benchmarkClipSeconds of synthetic video
-// at the rung's resolution using the real streaming encoder settings (libx264
-// superfast, no B-frames) to /dev/null and returns clipDuration/wallTime — the
-// realtime factor. ok=false when the probe couldn't run, so the caller skips
-// rather than treating the failure as a fast result. Each probe is bounded so
-// a wedged ffmpeg can't stall daemon startup.
-func measureEncodeRealtimeFactor(ctx context.Context, ffmpegPath string, rung benchmarkRung) (float64, bool) {
- // A 3 s superfast encode that takes longer than 6 s is <0.5× realtime —
- // already far below the 2.0× bar — so capping here only kills genuinely
- // hopeless rungs early and bounds worst-case startup blocking (3 rungs ×
- // 6 s = 18 s) since this runs synchronously before the agent registers.
- bctx, cancel := context.WithTimeout(ctx, 6*time.Second)
- defer cancel()
-
- size := strconv.Itoa(rung.width) + "x" + strconv.Itoa(rung.height)
- args := []string{
- "-hide_banner", "-nostats", "-loglevel", "error",
- "-f", "lavfi",
- "-i", "testsrc2=size=" + size + ":rate=24:duration=" + strconv.Itoa(benchmarkClipSeconds),
- "-c:v", "libx264", "-preset", "superfast", "-threads", "0",
- "-bf", "0", "-sc_threshold", "0",
- "-f", "null", "-",
- }
- start := time.Now()
- err := exec.CommandContext(bctx, ffmpegPath, args...).Run()
- elapsed := time.Since(start)
- if err != nil || elapsed <= 0 {
- return 0, false
- }
- return float64(benchmarkClipSeconds) / elapsed.Seconds(), true
-}
diff --git a/internal/engine/encode_benchmark_test.go b/internal/engine/encode_benchmark_test.go
deleted file mode 100644
index 20f2e64..0000000
--- a/internal/engine/encode_benchmark_test.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package engine
-
-import (
- "context"
- "os/exec"
- "testing"
-)
-
-func TestBenchmarkMaxTranscodeHeight_HardwareSkipsProbe(t *testing.T) {
- // Hardware encoders return 2160 without touching ffmpeg — pass a bogus path
- // to prove no subprocess runs.
- for _, hw := range []HWAccel{HWAccelNVENC, HWAccelQSV, HWAccelVAAPI, HWAccelVideoToolbox} {
- got := BenchmarkMaxTranscodeHeight(context.Background(), "/nonexistent/ffmpeg", hw)
- if got != 2160 {
- t.Errorf("hw=%s: got %d, want 2160", hw, got)
- }
- }
-}
-
-func TestBenchmarkMaxTranscodeHeight_NoFFmpegKeepsDefault(t *testing.T) {
- if got := BenchmarkMaxTranscodeHeight(context.Background(), "", HWAccelNone); got != 1080 {
- t.Errorf("empty ffmpeg path: got %d, want 1080 (historical default)", got)
- }
-}
-
-func TestBenchmarkMaxTranscodeHeight_SoftwareReturnsValidRung(t *testing.T) {
- ffmpeg, err := exec.LookPath("ffmpeg")
- if err != nil {
- t.Skip("ffmpeg not on PATH — software benchmark needs a real encoder")
- }
- got := BenchmarkMaxTranscodeHeight(context.Background(), ffmpeg, HWAccelNone)
- switch got {
- case 1080, 720, 480:
- // any rung is valid; the exact one depends on the host's CPU.
- default:
- t.Errorf("software ceiling = %d, want one of {1080,720,480}", got)
- }
-}
-
-func TestMeasureEncodeRealtimeFactor_RealEncoder(t *testing.T) {
- ffmpeg, err := exec.LookPath("ffmpeg")
- if err != nil {
- t.Skip("ffmpeg not on PATH")
- }
- factor, ok := measureEncodeRealtimeFactor(context.Background(), ffmpeg, benchmarkRung{height: 480, width: 854})
- if !ok {
- t.Fatal("480p probe failed to run on a host with ffmpeg")
- }
- if factor <= 0 {
- t.Errorf("realtime factor = %.2f, want > 0", factor)
- }
-}
diff --git a/internal/engine/hls.go b/internal/engine/hls.go
index ea097bf..9524627 100644
--- a/internal/engine/hls.go
+++ b/internal/engine/hls.go
@@ -22,69 +22,20 @@ import (
"fmt"
"io"
"log"
- "math"
"net/http"
"os"
"os/exec"
"path/filepath"
- "regexp"
"strconv"
"strings"
"sync"
"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)
-}
-
-// segmentIdxForTime returns the index of the segment containing second `sec`
-// of the timeline — the inverse of segmentStartSec. Used to translate a
-// session's StartSec (resume position) into the segment the FIRST ffmpeg
-// should start writing from.
-func segmentIdxForTime(sec float64) int {
- if sec <= 0 {
- return 0
- }
- return int(sec / float64(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.
@@ -143,109 +94,12 @@ func CleanupHLSOrphanDirs() error {
// HLSSessionConfig describes a single browser playback session driven by HLS.
type HLSSessionConfig struct {
- SessionID string
- // Exactly one of SourcePath / SourceURL identifies the input. SourcePath is
- // a local file; SourceURL is a remote HTTP(S) URL ffmpeg reads directly
- // (hueco #2 / 2b — transcoding a debrid source that isn't browser-native).
+ SessionID string
SourcePath string
- // SourceURL, when set, is fed to ffmpeg/ffprobe as the input (-i ) with
- // network-resilience flags. Takes priority over SourcePath.
- SourceURL string
- // CacheID overrides the cache key identity. Empty → key by SourcePath (local
- // files). Set to a stable id (the torrent info_hash) for SourceURL sessions
- // so re-plays cache-hit even though the debrid URL changes each resolution.
- CacheID string
- // RefreshURL, when set (debrid URL sessions only), re-resolves a fresh
- // SourceURL when the current link expires mid-transcode (hueco #2 / 2c).
- // The auto-restart supervisor calls it before relaunching ffmpeg so the
- // restart uses a live link instead of retrying the dead one. nil = no refresh.
- RefreshURL func(context.Context) (string, error)
FileName string
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
- // BurnSubtitleIndex burns a BITMAP subtitle (PGS/DVB) at this 0-based
- // subtitle stream index into the video. nil = no burn (text subs are served
- // as separate WebVTT). A pointer (not int) so the zero value 0 — a valid
- // stream index — can't be mistaken for a burn request when a caller leaves
- // the field unset. Part of the cache key so a burned encode never collides
- // with the clean one. Forces the video re-encode the HLS path already does
- // to also composite the subtitle overlay.
- BurnSubtitleIndex *int
- // StartSec is the playback position (seconds) the viewer will start at —
- // the saved resume point, or the current position on a quality/audio
- // switch. When > 0 the FIRST ffmpeg spawns already seeked there
- // (`-ss` + `-output_ts_offset` + `-start_number`, the same flags as a
- // seek-restart), instead of encoding from segment 0 only to be
- // killed by an immediate seek-restart when the player asks for the resume
- // segment (double spawn, slow resume). 0 = start at the beginning.
- // Ignored on a cache HIT (every segment is already on disk).
- StartSec float64
- // Prewarm marks a background cache-fill session. The daemon defers its
- // encode until no live encode runs and registers it via RegisterKeep
- // (never evicting the viewer). It also lets a REAL session close stale
- // prewarms up front so the cache writer-lock is free for the viewer.
- Prewarm bool
- 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
- // VideoCopy switches the session to HLS-copy mode: ffmpeg `-c:v copy`
- // (NEVER re-encodes video — I/O-bound, works on a GPU-less NAS), audio
- // copied when already AAC or re-encoded to AAC otherwise. This replaces
- // the fragile progressive-remux path (growing fMP4 over manual HTTP
- // Range) with the robust segmented transport every player handles
- // (hls.js + native iOS HLS). Differences from the encode mode, all
- // driven by "segments cut at the SOURCE's keyframes, so their durations
- // are unknown upfront":
- // - the media playlist is ffmpeg's own (EVENT → ENDLIST), served from
- // disk — not the pre-rendered uniform-2s VOD manifest;
- // - no seek-restart / auto-restart (copy outruns any viewer: the whole
- // file is remuxed at I/O speed, minutes at worst on a weak NAS);
- // - no HLS cache (re-generating costs no encode — caching would only
- // burn disk);
- // - StartSec is ignored: copy produces from 0 (outruns playback at I/O
- // speed); an offset EVENT playlist breaks iOS's native HLS parser.
- // See docs/plans/hls-copy-remux-replacement.md (web repo).
- VideoCopy bool
-}
-
-// copyPlaylistName is the on-disk media playlist ffmpeg owns in VideoCopy
-// mode, under /video/. Distinct from the encode mode's in-memory
-// manifest so the two can never be confused.
-const copyPlaylistName = "copy.m3u8"
-
-// sourceRef returns the ffmpeg/ffprobe input: the remote URL when set, else the
-// local path. Used everywhere a `-i` argument or a probe target is needed so
-// the local-file and debrid-URL paths share one code path.
-func (cfg HLSSessionConfig) sourceRef() string {
- if cfg.SourceURL != "" {
- return cfg.SourceURL
- }
- return cfg.SourcePath
-}
-
-// burnSubtitleIndexOrNone resolves the optional burn-in subtitle pointer to the
-// int sentinel the cache key and filtergraph use: nil → -1 ("no burn").
-func (cfg HLSSessionConfig) burnSubtitleIndexOrNone() int {
- if cfg.BurnSubtitleIndex == nil {
- return -1
- }
- return *cfg.BurnSubtitleIndex
-}
-
-// logName is a short, log-friendly source label. For local files it's the base
-// name; for a URL source (no SourcePath) it prefers FileName over the raw URL
-// (which would leak a query-string token into the logs).
-func (cfg HLSSessionConfig) logName() string {
- if cfg.SourcePath != "" {
- return filepath.Base(cfg.SourcePath)
- }
- if cfg.FileName != "" {
- return cfg.FileName
- }
- return "debrid-url"
+ Transcode TranscodeRuntime
}
// HLSSession owns a tmpdir + ffmpeg subprocess producing HLS fragments.
@@ -276,53 +130,15 @@ type HLSSession struct {
ffmpegSegStart int // index of the first segment the current ffmpeg writes
restartCount int // bounded auto-restart counter (resets on Close)
lastRestartAt time.Time
- // liveURL is the mutable debrid source URL (hueco #2 / 2c). Initialised to
- // cfg.SourceURL; refreshed in place by waitFFmpeg when the link expires.
- // Guarded by mu because restartFromSegment reads it from BOTH the supervisor
- // goroutine (auto-restart) AND the HTTP handler goroutine (seek-restart),
- // while waitFFmpeg writes it. Empty for local-file sessions. cfg itself is
- // treated as immutable after construction so copying it stays race-free.
- liveURL string
- // 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.
- cache *HLSCache
- cacheKey string
- fromCache bool
- writerLockHeld bool
-
- // Live transcode telemetry (F3). ffmpeg's -stats progress line is parsed
- // in hlsStderrCapture.Write into an EWMA of speed= (×realtime) + fps=, plus
- // an input-bound hint set when the SOURCE read errors (slow/broken pull vs a
- // too-slow encode). GetTranscodeStats() snapshots this so the ready-watcher
- // can report a real measurement to the web side — letting the player name a
- // too-slow transcode honestly in ~4s instead of inferring it from stall
- // shape over 15-30s. Guarded by statsMu (the stderr goroutine writes; the
- // watcher goroutine reads).
- statsMu sync.Mutex
- speedEWMA float64
- fpsEWMA float64
- speedSamples int
- warmupSeen int // cold-start frames discarded before the EWMA is trusted
- // Walltime of the LAST source-read error ffmpeg reported. Windowed (see
- // hlsInputBoundWindow) instead of a sticky bool: with the F1 continuous
- // monitor a single transient read blip (peer drop, debrid hiccup ffmpeg
- // reconnects through) must not reclassify every sub-realtime dip as
- // "input_bound/struggling" for the rest of a multi-hour session.
- inputErrAt time.Time
}
// hlsSeekAhead is how many segments past the writer's current position the
@@ -374,78 +190,6 @@ func (r *HLSSessionRegistry) Register(s *HLSSession) {
}
}
-// CloseWhere closes + removes every registered session matching pred. Used
-// by the REAL-session path to reap stale prewarm encodes BEFORE its own
-// StartHLSSession runs — that frees the per-key cache writer-lock, so the
-// viewer's encode lands in the persistent cache instead of falling back to
-// an uncached per-session tmpdir (and a SEALED prewarm survives as a cache
-// HIT: closing a from-cache reader never invalidates the entry).
-func (r *HLSSessionRegistry) CloseWhere(pred func(*HLSSession) bool) int {
- r.mu.Lock()
- victims := make([]*HLSSession, 0, len(r.sessions))
- for id, s := range r.sessions {
- if pred(s) {
- victims = append(victims, s)
- delete(r.sessions, id)
- }
- }
- r.mu.Unlock()
- for _, s := range victims {
- _ = s.Close()
- }
- return len(victims)
-}
-
-// IsPrewarm reports whether this session was started as a background
-// cache-fill (HLSSessionConfig.Prewarm). cfg is immutable after construction.
-func (s *HLSSession) IsPrewarm() bool { return s.cfg.Prewarm }
-
-// IsVideoCopy reports whether this session serves -c:v copy (no video
-// re-encode). Copy sessions emit no ffmpeg -stats telemetry, so the ready
-// watcher posts a one-shot "copy" health heartbeat instead of waiting for
-// speed= samples that never arrive.
-func (s *HLSSession) IsVideoCopy() bool { return s.cfg.VideoCopy }
-
-// RegisterKeep adds a session WITHOUT displacing the others — the prewarm
-// path: a background cache-fill encode must not evict the viewer's live
-// session (Register's eviction killed the stream being watched when the
-// next-episode prewarm got claimed mid-playback). It still replaces (and
-// closes) a previous session with the SAME ID. A later Register() of a real
-// viewer session evicts prewarms like any other session — a completed
-// (sealed) prewarm survives in the segment cache either way.
-func (r *HLSSessionRegistry) RegisterKeep(s *HLSSession) {
- r.mu.Lock()
- prev := r.sessions[s.cfg.SessionID]
- r.sessions[s.cfg.SessionID] = s
- r.mu.Unlock()
- if prev != nil && prev != s {
- _ = prev.Close()
- }
-}
-
-// HasLiveEncode reports whether any registered session still has a RUNNING
-// ffmpeg (encode not finished). Used to defer prewarm encodes so they never
-// compete with the viewer's live transcode for the encoder.
-func (r *HLSSessionRegistry) HasLiveEncode() bool {
- r.mu.RLock()
- defer r.mu.RUnlock()
- for _, s := range r.sessions {
- if !s.EncodeExited() {
- return true
- }
- }
- return false
-}
-
-// Count reports how many sessions are currently registered (live or recently
-// finished but not yet swept). Used by the graceful auto-upgrade gate to defer
-// applying an update while the agent is actively streaming.
-func (r *HLSSessionRegistry) Count() int {
- r.mu.RLock()
- defer r.mu.RUnlock()
- return len(r.sessions)
-}
-
// Remove drops a session from the registry without closing it.
func (r *HLSSessionRegistry) Remove(id string) {
r.mu.Lock()
@@ -498,8 +242,8 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
if !validSessionID.MatchString(cfg.SessionID) {
return nil, errors.New("hls: invalid session id")
}
- if cfg.SourcePath == "" && cfg.SourceURL == "" {
- return nil, errors.New("hls: no source (neither path nor URL)")
+ if cfg.SourcePath == "" {
+ return nil, errors.New("hls: empty source path")
}
if cfg.Transcode.FFmpegPath == "" || cfg.Transcode.FFprobePath == "" {
return nil, errors.New("hls: ffmpeg/ffprobe not available")
@@ -510,7 +254,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
// the goroutine that started the session forever and the user would
// see the player phase stuck on "Preparando sesión".
probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second)
- probe, err := ProbeFile(probeCtx, cfg.Transcode.FFprobePath, cfg.sourceRef())
+ probe, err := ProbeFile(probeCtx, cfg.Transcode.FFprobePath, cfg.SourcePath)
cancelProbe()
if err != nil {
return nil, fmt.Errorf("hls: probe: %w", err)
@@ -519,177 +263,42 @@ 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.VideoCopy && cfg.Cache != nil {
- // HLS-copy never caches: re-generating costs no encode (I/O-bound), so
- // persisting segments would only burn cache budget that real transcodes
- // need. Private per-session tmpdir, deleted on Close.
- cfg.Cache = nil
- }
- if cfg.Cache != nil {
- // Debrid URL sessions key by CacheID (info_hash) so re-plays hit cache
- // despite the URL changing each resolution; local files key by path.
- if cfg.CacheID != "" {
- cacheKey = cfg.Cache.KeyForID(cfg.CacheID, cfg.Quality, cfg.AudioIndex, cfg.burnSubtitleIndexOrNone())
- } else {
- cacheKey = cfg.Cache.KeyFor(cfg.SourcePath, cfg.Quality, cfg.AudioIndex, cfg.burnSubtitleIndexOrNone())
- }
- // 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 {
+ 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,
- probe: probe,
- tmpDir: tmpDir,
- durationSec: probe.DurationSec,
- segmentCount: segCount,
- startedAt: time.Now(),
- lastTouch: time.Now(),
- readyCh: make(chan struct{}),
- cache: cfg.Cache,
- cacheKey: cacheKey,
- fromCache: fromCache,
- writerLockHeld: writerLockHeld,
- liveURL: cfg.SourceURL, // mutable copy; cfg stays immutable
+ cfg: cfg,
+ probe: probe,
+ tmpDir: tmpDir,
+ durationSec: probe.DurationSec,
+ segmentCount: segCount,
+ startedAt: time.Now(),
+ lastTouch: time.Now(),
+ readyCh: make(chan struct{}),
}
- if cfg.VideoCopy {
- // Copy mode: ffmpeg owns the media playlist (segments cut at the
- // source's keyframes → durations unknown upfront, the uniform-2s
- // pre-render would lie). ServeVideoPlaylist reads it from disk.
- s.manifestVideo = ""
- s.manifestRoot = renderMasterPlaylistCopy(probe)
- } else {
- 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, cfg.logName(),
- probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"))
- return s, nil
- }
-
- // Resume-aware first spawn: when the session carries a StartSec (resume
- // point / position on a quality switch), launch ffmpeg already seeked at
- // the segment containing it. The web player opens playback at the same
- // position (hls.js startPosition), so segment 0 would never be requested —
- // encoding from 0 just to seek-restart milliseconds later wasted a full
- // ffmpeg spawn and doubled the resume latency. Earlier segments simply
- // don't exist on disk; ServeSegment's `idx < segStart` branch restarts the
- // encoder if the user later scrubs back before the resume point. A partial
- // encode never seals the cache (allSegmentsPresent checks 0..N), matching
- // today's post-seek behaviour.
- startIdx := 0
- if cfg.VideoCopy {
- // Copy mode always starts from 0: segment indices don't map to
- // uniform 2s slots, so a StartSec-derived index would be wrong.
- // StartSec is intentionally ignored (see buildHLSCopyArgs); the
- // player seeks to the resume point via its own startPosition once
- // the growing playlist reaches that position.
- } else if cfg.StartSec > 0 && cfg.StartSec < probe.DurationSec {
- startIdx = segmentIdxForTime(cfg.StartSec)
- if startIdx > segCount-1 {
- startIdx = segCount - 1
- }
- } else if cfg.StartSec >= probe.DurationSec && cfg.StartSec > 0 {
- // Stale resume beyond this source's duration (the file was replaced by
- // a shorter cut, or progress was saved against another release). Start
- // from the beginning instead of encoding only the final segment, which
- // would "end" the video seconds after it starts.
- log.Printf("[hls %s] startSec %.0f ≥ duration %.0f — starting from 0",
- shortHLSID(cfg.SessionID), cfg.StartSec, probe.DurationSec)
- }
- s.ffmpegSegStart = startIdx
- s.readyMax = startIdx
+ s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
+ s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
// Spawn ffmpeg under a dedicated context so Close() can kill it without
// touching the parent ctx.
ffCtx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
- var args []string
- if cfg.VideoCopy {
- args = buildHLSCopyArgs(cfg, probe, tmpDir)
- } else {
- args = buildHLSFFmpegArgsAt(cfg, probe, tmpDir, startIdx, segmentStartSec(startIdx))
- }
+ args := buildHLSFFmpegArgs(cfg, probe, tmpDir)
cmd := exec.CommandContext(ffCtx, cfg.Transcode.FFmpegPath, args...)
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
@@ -697,44 +306,13 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
go s.waitFFmpeg()
go s.pollSegments(ffCtx)
- // Subtitles are no longer extracted per-session: the web player fetches each
- // text track on demand as WebVTT from the /sub endpoint (subtitleHandler).
- // The old per-session extraction wrote subs/sub-N.vtt that nothing requests
- // anymore (the master playlist no longer advertises a SUBTITLES group), so
- // it was pure wasted ffmpeg work — and its Close() wait could block HLS cache
- // persistence on a slow extract. Removed.
+ if len(probe.SubtitleTracks) > 0 {
+ 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).
- encoderNote := ""
- if cfg.VideoCopy {
- encoderNote = "encoder=copy (no video re-encode)"
- } else {
- profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
- presetNote := ""
- if profile.Preset != "" {
- presetNote = " preset=" + profile.Preset
- }
- encoderNote = fmt.Sprintf("encoder=%s accel=%s%s", profile.Codec, string(cfg.Transcode.HWAccel), presetNote)
- }
- startNote := ""
- if cfg.VideoCopy && cfg.StartSec > 0 {
- // Copy ignores StartSec on purpose (see buildHLSCopyArgs) — log the
- // requested resume point honestly so nobody reads "ffmpeg seeked".
- startNote = fmt.Sprintf(" resume=%.0fs requested (copy encodes from 0)", cfg.StartSec)
- } else if startIdx > 0 {
- startNote = fmt.Sprintf(" start=seg-%d@%.0fs", startIdx, segmentStartSec(startIdx))
- }
- log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, %s)%s%s",
- shortHLSID(cfg.SessionID), cfg.logName(),
- probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
- encoderNote, cachedNote, startNote)
+ 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"))
return s, nil
}
@@ -765,18 +343,13 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
subs := make([]map[string]any, 0, len(s.probe.SubtitleTracks))
for _, sb := range s.probe.SubtitleTracks {
- // `external`/`path` let the stream server attach a tokened /sub vttUrl
- // (path-addressed for sidecars, index-addressed for embedded). `path` is
- // stripped after the URL is built so the raw path isn't doubled in JSON.
subs = append(subs, map[string]any{
- "index": sb.Index,
- "lang": sb.Lang,
- "codec": sb.Codec,
- "title": sb.Title,
- "forced": sb.Forced,
- "text": sb.IsTextSubtitle(),
- "external": sb.External,
- "path": sb.Path,
+ "index": sb.Index,
+ "lang": sb.Lang,
+ "codec": sb.Codec,
+ "title": sb.Title,
+ "forced": sb.Forced,
+ "text": sb.IsTextSubtitle(),
})
}
return map[string]any{
@@ -792,133 +365,6 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
}
-// ReadyCount returns the session's readyMax watermark: segment idx is on disk
-// iff idx < ReadyCount() AND idx >= WriterStartIdx(). For a from-zero encode
-// this is simply "how many segments are on disk"; for a resume session
-// (StartSec > 0) readyMax is pre-seeded to the start index, so the FIRST real
-// segment has landed only once ReadyCount() > WriterStartIdx() — use that
-// comparison, not `>= 1`, to flip the player's "Preparando…" UI. 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
-}
-
-// EncodeExited reports whether this session's ffmpeg has finished (clean or
-// crashed) or never ran (cache HIT). False while an encode is producing
-// segments. Used by HasLiveEncode to defer prewarm work.
-func (s *HLSSession) EncodeExited() bool {
- s.readyMu.Lock()
- defer s.readyMu.Unlock()
- return s.exited
-}
-
-// WriterStartIdx returns the segment index the CURRENT ffmpeg writer started
-// at: 0 for a from-the-beginning encode, the resume segment for a StartSec
-// session, the seek target after a seek-restart. See ReadyCount for the
-// "first segment landed" comparison.
-func (s *HLSSession) WriterStartIdx() int {
- s.mu.Lock()
- defer s.mu.Unlock()
- return s.ffmpegSegStart
-}
-
-// 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 }
-
-// TranscodeStats is a point-in-time snapshot of live ffmpeg progress for one
-// HLS session (F3). SpeedX < 1.0 means the encode runs slower than realtime —
-// the player can't sustain playback without buffering. Samples==0 means no
-// -stats line has been parsed yet (the watcher keeps waiting before reporting).
-type TranscodeStats struct {
- SpeedX float64 // EWMA of ffmpeg speed= (×realtime; 1.0 = exactly realtime)
- Fps float64 // EWMA of ffmpeg fps=
- Samples int // progress lines parsed so far (0 = no telemetry yet)
- InputBound bool // source read hit I/O errors (slow/broken pull, not encode)
- FromCache bool // replayed from cache → no live encode, stats meaningless
-}
-
-// GetTranscodeStats returns a snapshot of the parsed ffmpeg progress EWMAs.
-func (s *HLSSession) GetTranscodeStats() TranscodeStats {
- s.statsMu.Lock()
- defer s.statsMu.Unlock()
- return TranscodeStats{
- SpeedX: s.speedEWMA,
- Fps: s.fpsEWMA,
- Samples: s.speedSamples,
- InputBound: !s.inputErrAt.IsZero() && time.Since(s.inputErrAt) < hlsInputBoundWindow,
- FromCache: s.fromCache,
- }
-}
-
-// hlsInputBoundWindow bounds how long a source-read error keeps classifying
-// the session as input-bound. Past it, a sub-realtime encode is the encoder's
-// own problem again (the transient link blip resolved or ffmpeg reconnected).
-const hlsInputBoundWindow = 30 * time.Second
-
-// hlsStatsWarmupSkip is how many leading -stats frames to discard before
-// trusting the EWMA. ffmpeg's first readings reflect the pipeline filling
-// (often speed=0.0x) and would otherwise drag a healthy encoder into a false
-// "struggling" verdict that pauses a stream which plays fine once warmed up.
-const hlsStatsWarmupSkip = 2
-
-// recordProgress folds one parsed ffmpeg -stats sample into the session EWMAs.
-// alpha=0.3 smooths the noisy per-line numbers while still tracking a sustained
-// slowdown within a few samples (~2s of encoding).
-func (s *HLSSession) recordProgress(speedX, fps float64) {
- s.statsMu.Lock()
- defer s.statsMu.Unlock()
- // Drop the cold-start frames so a steady-state slowdown — not the encoder
- // spin-up — is what the watcher reports.
- if s.warmupSeen < hlsStatsWarmupSkip {
- s.warmupSeen++
- return
- }
- const alpha = 0.3
- if s.speedSamples == 0 {
- s.speedEWMA = speedX
- s.fpsEWMA = fps
- } else {
- s.speedEWMA = alpha*speedX + (1-alpha)*s.speedEWMA
- s.fpsEWMA = alpha*fps + (1-alpha)*s.fpsEWMA
- }
- s.speedSamples++
-}
-
-// markInputBound flags that ffmpeg reported a source-read error — the wall is
-// the input pull (slow debrid link / dropped torrent peer), not the encoder.
-func (s *HLSSession) markInputBound() {
- s.statsMu.Lock()
- s.inputErrAt = time.Now()
- s.statsMu.Unlock()
-}
-
-// resetTranscodeStats re-arms the cold-start warmup and drops the EWMAs +
-// input-error mark. MUST be called whenever a NEW ffmpeg process starts
-// inside the same session (seek restart, auto-restart supervisor): the new
-// process's pipeline-fill frames read speed=0.0x, and folding them into the
-// already-warmed EWMA drags a healthy 1.5x encode under the 0.75 struggling
-// floor in two samples — which the F1 health monitor would then report as a
-// false "struggling" (pausing the player) right at the seek the user made.
-func (s *HLSSession) resetTranscodeStats() {
- s.statsMu.Lock()
- s.warmupSeen = 0
- s.speedSamples = 0 // recordProgress re-seeds the EWMA on the next sample
- s.inputErrAt = time.Time{}
- s.statsMu.Unlock()
-}
-
-// 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 }
@@ -939,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 {
@@ -968,34 +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
- }
- if 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)
}
@@ -1003,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
@@ -1051,15 +438,6 @@ func (s *HLSSession) waitFFmpeg() {
}
log.Printf("[hls %s] ffmpeg exited: %v", shortHLSID(s.cfg.SessionID), err)
- // Copy mode: no auto-restart. restartFromSegment's `-ss segmentStartSec(N)`
- // math assumes uniform 2s segments, which copy mode doesn't have — a
- // restart would corrupt the timeline. A failed copy surfaces through the
- // player's probe deadline / fallback chain instead.
- if s.cfg.VideoCopy {
- log.Printf("[hls %s] copy session failed — not restarting (player falls back)", shortHLSID(s.cfg.SessionID))
- return
- }
-
// Decide whether to attempt an auto-restart. We don't restart when:
// - the session was closed externally (kill on quality change etc.)
// - we've already retried 3 times within the last 60 s (broken file)
@@ -1085,26 +463,6 @@ func (s *HLSSession) waitFFmpeg() {
s.lastRestartAt = time.Now()
s.mu.Unlock()
- // Debrid URL session (hueco #2 / 2c): the likeliest cause of an ffmpeg
- // network exit is the debrid link expiring. Re-resolve a fresh one before
- // restarting, else the restart just retries the dead URL and burns the
- // retry budget. The network call runs lock-free; the result is stored in
- // s.liveURL under s.mu because restartFromSegment reads it from the HTTP
- // handler goroutine too (seek-restart), not just this supervisor goroutine.
- if s.cfg.SourceURL != "" && s.cfg.RefreshURL != nil {
- rctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
- newURL, rerr := s.cfg.RefreshURL(rctx)
- cancel()
- if rerr != nil {
- log.Printf("[hls %s] URL refresh before restart failed: %v", shortHLSID(s.cfg.SessionID), rerr)
- } else {
- s.mu.Lock()
- s.liveURL = newURL
- s.mu.Unlock()
- log.Printf("[hls %s] debrid URL refreshed before restart", shortHLSID(s.cfg.SessionID))
- }
- }
-
// Restart from the last segment we know is safely on disk. If readyMax
// is 0 (never produced anything), retry from segment 0 — covers initial
// startup failures on transient errors.
@@ -1151,22 +509,14 @@ func (s *HLSSession) pollSegments(ctx context.Context) {
}
// Last segment is "ready" only when ffmpeg has exited (no successor
// can ever appear) or when a later segment exists.
- //
- // For VideoCopy sessions, segmentCount is the encode-mode estimate
- // (ceil(dur/2s)) and is always larger than the real segment count
- // on wide-GOP sources (keyframe-cut → fewer segments). We must
- // NOT rely solely on `i == s.segmentCount-1` to detect the last
- // real segment — when exited and no successor exists the current
- // segment IS the last one, regardless of its index.
- noSuccessor := func() bool { _, e := os.Stat(next); return e != nil }
- if i == s.segmentCount-1 || (exited && noSuccessor()) {
+ if i == s.segmentCount-1 {
if !exited {
break
}
highest = i + 1
break
}
- if noSuccessor() {
+ if _, err := os.Stat(next); err != nil {
break
}
highest = i + 1
@@ -1181,14 +531,7 @@ func (s *HLSSession) pollSegments(ctx context.Context) {
close(ch)
}
}
- // Exit when all expected segments are ready. For encode mode,
- // segmentCount is exact; for VideoCopy it's an overestimate, but the
- // `exited && noSuccessor()` branch above always marks the real last
- // segment, so highest will reach segmentCount only if the source
- // happens to have exactly that many keyframe segments — or never if
- // it has fewer. Exit also when exited and highest stopped advancing
- // (no more segments will ever appear).
- if exited && (highest >= s.segmentCount || highest == start) {
+ if exited && highest >= s.segmentCount {
return
}
}
@@ -1251,57 +594,9 @@ func (s *HLSSession) ServeVideoPlaylist(w http.ResponseWriter, r *http.Request)
s.Touch()
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
w.Header().Set("Cache-Control", "no-cache")
- if s.cfg.VideoCopy {
- s.serveCopyPlaylist(w, r)
- return
- }
_, _ = io.WriteString(w, s.manifestVideo)
}
-// serveCopyPlaylist serves ffmpeg's own media playlist (VideoCopy mode). The
-// file appears within ~1 s of spawn (copy is I/O-bound) but the player's
-// first fetch can race it — poll briefly instead of returning a 404 hls.js
-// would surface as a manifest error. Each request re-reads the file: the
-// playlist GROWS (EVENT) until ffmpeg appends ENDLIST, and players re-poll
-// growing playlists by design.
-func (s *HLSSession) serveCopyPlaylist(w http.ResponseWriter, r *http.Request) {
- path := filepath.Join(s.tmpDir, "video", copyPlaylistName)
- deadline := time.Now().Add(10 * time.Second)
- for {
- data, err := os.ReadFile(path)
- if err == nil && len(data) > 0 {
- // Until ENDLIST lands a copy session is a growing EVENT playlist,
- // and some native players (iOS) treat any not-yet-ended playlist
- // like LIVE and join at the live edge instead of position 0.
- // EXT-X-START pins the start to 0 explicitly (RFC 8216 §4.3.5.2);
- // harmless once the playlist is final.
- out := data
- if !strings.Contains(string(data), "#EXT-X-START") {
- // Anchor on #EXTM3U (REQUIRED first line per RFC 8216) instead
- // of a specific VERSION value, so an ffmpeg that bumps the
- // playlist version can't silently skip the injection.
- replaced := strings.Replace(string(data),
- "#EXTM3U\n",
- "#EXTM3U\n#EXT-X-START:TIME-OFFSET=0,PRECISE=YES\n", 1)
- if replaced == string(data) {
- log.Printf("[hls %s] WARNING: EXT-X-START injection failed (no #EXTM3U header?)", shortHLSID(s.cfg.SessionID))
- }
- out = []byte(replaced)
- }
- _, _ = w.Write(out)
- return
- }
- if r.Context().Err() != nil || time.Now().After(deadline) {
- http.Error(w, "playlist not ready", http.StatusServiceUnavailable)
- return
- }
- select {
- case <-r.Context().Done():
- case <-time.After(100 * time.Millisecond):
- }
- }
-}
-
// ServeInit writes init.mp4 (the fMP4 init segment) to w.
func (s *HLSSession) ServeInit(w http.ResponseWriter, r *http.Request) {
s.Touch()
@@ -1333,11 +628,7 @@ func (s *HLSSession) ServeInit(w http.ResponseWriter, r *http.Request) {
// in real time (~25 minutes wait at 1080p software encode).
func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx int) {
s.Touch()
- // segmentCount is exact for the encode mode (uniform 2s slots) but only an
- // ESTIMATE for copy mode (cuts go at source keyframes): a short-GOP source
- // can legitimately produce more segments than the estimate, and bounding
- // would 404 the real tail. Copy trusts ffmpeg's playlist as the authority.
- if idx < 0 || (!s.cfg.VideoCopy && idx >= s.segmentCount) {
+ if idx < 0 || idx >= s.segmentCount {
http.Error(w, "segment out of range", http.StatusNotFound)
return
}
@@ -1362,10 +653,7 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in
readyMax := s.readyMax
s.readyMu.Unlock()
- // Copy mode never seek-restarts: ffmpeg outruns playback (I/O-bound), the
- // playlist only lists fully-written segments (temp_file), and segment
- // indices don't map to uniform 2s slots anyway. Just wait for the writer.
- if !s.cfg.VideoCopy && (idx >= readyMax+hlsSeekAhead || idx < segStart) {
+ if idx >= readyMax+hlsSeekAhead || idx < segStart {
if err := s.restartFromSegment(idx); err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
@@ -1385,12 +673,6 @@ func (s *HLSSession) ServeSegment(w http.ResponseWriter, r *http.Request, idx in
// `-ss` offset corresponds to segment `targetIdx`. The caller must NOT hold
// s.mu when calling — the function takes both s.mu and s.readyMu.
func (s *HLSSession) restartFromSegment(targetIdx int) error {
- if s.cfg.VideoCopy {
- // Defensive: callers already gate on VideoCopy, but the `-ss
- // segmentStartSec(N)` math below assumes uniform 2s segments and
- // would corrupt a copy session's keyframe-cut timeline.
- return errors.New("hls: seek-restart not supported in copy mode")
- }
s.mu.Lock()
if s.closed {
s.mu.Unlock()
@@ -1433,18 +715,9 @@ 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.
- // Use a local cfg copy carrying the live (possibly-refreshed) debrid URL,
- // read under s.mu — this runs from the HTTP handler goroutine too, so it
- // can't read s.liveURL unsynchronised while waitFFmpeg writes it (2c).
- startSec := segmentStartSec(targetIdx)
- cfg := s.cfg
- s.mu.Lock()
- cfg.SourceURL = s.liveURL // "" for local-file sessions — no-op, sourceRef falls back to SourcePath
- s.mu.Unlock()
- args := buildHLSFFmpegArgsAt(cfg, s.probe, s.tmpDir, targetIdx, startSec)
+ // 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())
cmd := exec.CommandContext(ffCtx, s.cfg.Transcode.FFmpegPath, args...)
@@ -1455,7 +728,6 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
}
// Reset session state so the poll + wait machinery picks up the new run.
- s.resetTranscodeStats() // new ffmpeg = new cold ramp; don't poison the EWMA
s.mu.Lock()
s.cmd = cmd
s.cancel = cancel
@@ -1477,59 +749,36 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
return nil
}
-// ---- ffmpeg argument builders ----
-
-// 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 ""
+// ServeSubtitle writes the WebVTT subtitle for the requested track index, if
+// extraction has finished.
+func (s *HLSSession) ServeSubtitle(w http.ResponseWriter, r *http.Request, idx int) {
+ s.Touch()
+ if idx < 0 || idx >= len(s.probe.SubtitleTracks) {
+ http.Error(w, "subtitle track not found", http.StatusNotFound)
+ return
+ }
+ path := filepath.Join(s.tmpDir, "subs", fmt.Sprintf("sub-%d.vtt", idx))
+ deadline := time.Now().Add(15 * time.Second)
+ for {
+ if fi, err := os.Stat(path); err == nil && fi.Size() > 0 {
+ break
+ }
+ if s.isClosed() || time.Now().After(deadline) {
+ http.Error(w, "subtitle not yet extracted", http.StatusServiceUnavailable)
+ return
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
+ w.Header().Set("Cache-Control", "max-age=3600")
+ http.ServeFile(w, r, path)
}
-// 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: ""}
+// ---- ffmpeg argument builders ----
+
+// buildHLSFFmpegArgs returns the argv for the initial HLS encode (start at 0).
+func buildHLSFFmpegArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) []string {
+ return buildHLSFFmpegArgsAt(cfg, probe, tmpDir, 0, 0)
}
// buildHLSFFmpegArgsAt returns the argv for an HLS encode that starts at the
@@ -1537,53 +786,18 @@ func ResolveEncoderProfile(hw HWAccel, configuredPreset string) EncoderProfile {
// 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)
- // -stats forces ffmpeg to emit the frame=/fps=/speed= progress line to
- // stderr even at -loglevel warning; hlsStderrCapture parses it for live
- // transcode telemetry (F3) without logging it.
- args := []string{"-y", "-hide_banner", "-loglevel", "warning", "-stats"}
+ hwHint := cfg.Transcode.HWAccel
+ args := []string{"-y", "-hide_banner", "-loglevel", "warning"}
- // F4 — full-GPU NVENC downscale. When we're downscaling an SDR source with
- // NVENC on a host whose ffmpeg can run scale_cuda, and NO subtitle is burned
- // in, keep the decoded frame on the GPU through scale + encode (scale_cuda →
- // h264_nvenc) instead of copying every frame to the CPU for `scale=`. That
- // CPU round-trip is the wall on modest GPUs (a strong box still gains ~37%).
- // Strictly gated — the cases that need CPU frames stay on the CPU path:
- // - HDR (the libplacebo Vulkan / zscale CPU tonemap can't consume a CUDA
- // surface, and mixing CUDA scale with the Vulkan pass is fragile),
- // - burn-in (the scale2ref+overlay composite runs on CPU frames),
- // - non-NVENC encoders, and no-op when not actually downscaling.
- // Output height cap for this session — resolved once here so the F4 gate and
- // the filter chain below share ONE value (a drift between them would emit
- // scale_cuda for a height that isn't actually a downscale).
- qcap := resolveQualityCap(cfg.Quality)
- maxH := qcap.MaxHeight
- if maxH == 0 {
- maxH = cfg.Transcode.MaxHeight
- }
- useCudaScale := profile.Codec == "h264_nvenc" &&
- profile.DecodeHwAccel == "cuda" &&
- cfg.Transcode.HasScaleCuda &&
- probe.HDR == "" &&
- cfg.burnSubtitleIndexOrNone() < 0 &&
- maxH > 0 && probe.Height > maxH
-
- // 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)
- // F4: pin decoded frames as CUDA surfaces ONLY on the gated scale_cuda
- // path, so scale_cuda + h264_nvenc avoid the CPU copy. Off otherwise —
- // the CPU filter chain can't consume CUDA surfaces.
- if useCudaScale {
- args = append(args, "-hwaccel_output_format", "cuda")
- }
+ 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
@@ -1593,52 +807,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
args = append(args, "-ss", strconv.FormatFloat(startSec, 'f', 3, 64))
}
- // Remote (debrid) input: make the HTTP read resilient. -reconnect* recovers
- // from a dropped/idle connection (debrid CDNs close long-idle sockets);
- // -rw_timeout (µs) bounds a stalled read so a hung CDN surfaces as a restart
- // instead of a frozen player. A seek (-ss before -i) re-opens the URL with a
- // Range request, which debrid supports. Flags are no-ops for local files, so
- // only add them for a URL source.
- if cfg.SourceURL != "" {
- args = append(args,
- "-reconnect", "1",
- "-reconnect_streamed", "1",
- "-reconnect_delay_max", "5",
- "-rw_timeout", "30000000",
- )
- }
-
- args = append(args, "-i", cfg.sourceRef())
+ args = append(args, "-i", cfg.SourcePath)
if startSec > 0 {
args = append(args, "-output_ts_offset", strconv.FormatFloat(startSec, 'f', 3, 64))
}
- // Burn a bitmap subtitle (PGS/DVB) into the video when requested. Validate
- // the index points at a real bitmap track in range — text subs are served as
- // separate WebVTT and never burned, and a stale/out-of-range index falls
- // back to a clean encode rather than failing the session.
- burnIdx := -1
- if reqBurn := cfg.burnSubtitleIndexOrNone(); reqBurn >= 0 {
- if reqBurn < len(probe.SubtitleTracks) &&
- !probe.SubtitleTracks[reqBurn].IsTextSubtitle() {
- burnIdx = reqBurn
- } else {
- log.Printf("[hls %s] burn subtitle %d ignored — not a bitmap track in range",
- shortHLSID(cfg.SessionID), reqBurn)
- }
- }
-
- // Map video + selected audio. With burn-in the video comes from the
- // filter_complex graph ([vout], built below); otherwise map the source video
- // stream directly. ffmpeg resolves the [vout] label from -filter_complex
- // regardless of argv order, so mapping it here (before audio) keeps video as
- // output stream 0.
- if burnIdx >= 0 {
- args = append(args, "-map", "[vout]")
- } else {
- args = append(args, "-map", "0:v:0")
- }
+ // Map video + selected audio. Always use first video stream.
+ args = append(args, "-map", "0:v:0")
audioIdx := cfg.AudioIndex
if audioIdx < 0 {
audioIdx = 0
@@ -1649,100 +825,33 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
}
}
}
- // Clamp to an audio track that actually exists. The web persists the chosen
- // audioIndex globally, so a value from a multi-track file can arrive for a
- // file with fewer tracks; `-map 0:a:N?` would then match nothing and the
- // optional `?` silently yields a VIDEO-ONLY stream (no sound — 2026-06-03,
- // Wistoria S02E08 had one audio track but the session carried audioIndex=2).
- // Fall back to the first track so audio is never silently dropped.
- if n := len(probe.AudioTracks); n > 0 && audioIdx >= n {
- log.Printf("[hls %s] audioIndex %d out of range (%d audio track(s)) — using 0:a:0",
- shortHLSID(cfg.SessionID), audioIdx, n)
- audioIdx = 0
- }
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.
- // -bf 0 (no B-frames) + -sc_threshold 0 (no scene-cut keyframes): both
- // remove the timestamp irregularities that make ffmpeg's HLS muxer emit
- // "Packet duration is out of range" on slightly-VFR / B-frame sources and
- // produce uneven segment lengths the player stutters on. Keyframe cadence
- // is driven by -force_key_frames below, so disabling scene-cut keeps every
- // segment exactly hls_time long.
- args = append(args, "-preset", profile.Preset, "-threads", "0", "-bf", "0", "-sc_threshold", "0")
+ preset := cfg.Transcode.Preset
+ if preset == "" {
+ preset = "veryfast"
+ }
+ args = append(args, "-preset", preset)
case "h264_nvenc":
- // p3 + vbr keeps NVENC fast (~1.5 s seg-0) without the segmentation
- // breakage `-tune ll` introduced in 0.9.9: with -tune=ll the NVENC
- // rate control emits long IDR-less GOPs that ignore -force_key_frames,
- // so ffmpeg's HLS muxer never closes seg-0 and the player stalls at
- // "preparando sesión" until the 60 s mark-ready timeout. Verified on
- // ffmpeg 6.1.1 + driver 580 / RTX-class GPUs: dropping -tune ll
- // restores per-segment cuts at 27x real-time vs 28x with -tune ll.
- // -bf 0 + -no-scenecut: same rationale as libx264 (NVENC's own flag for
- // scene-cut). No B-frame reorder → monotonic DTS → uniform segments, no
- // "Packet duration is out of range" flood. Safe with -force_key_frames
- // (unlike -tune ll, which broke per-segment cuts — see note above).
- // -forced-idr 1 is LOAD-BEARING: NVENC emits -force_key_frames frames
- // as plain (non-IDR) I-frames on current ffmpeg/driver combos, the HLS
- // muxer only cuts on IDR, and every segment silently stretches to the
- // default GOP (250 frames ≈ 10.4 s @24fps) while the server-rendered
- // playlist still promises hlsSegmentDuration. The PTS↔playlist mismatch
- // breaks seeks and desyncs subtitles (measured 2026-06-10: 3 segments
- // per 30 s instead of 15; with -forced-idr exactly 15).
- args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-bf", "0", "-no-scenecut", "1", "-forced-idr", "1")
+ // 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).
- // -forced_idr: same non-IDR forced-keyframe failure mode as NVENC (see
- // above) — QSV's AVOption spells it with an underscore.
- args = append(args, "-preset", profile.Preset, "-look_ahead", "0", "-forced_idr", "1")
- 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 FRAME (width × height), not just
- // height. A fixed "4.0" caps the encoder at 1080p; deriving by height alone
- // still under-levels anamorphic content — a 2.39:1 source scaled to 1080
- // height is ~2586×1080 = 11016 MBs, busting level 4.1's 8192-MB cap, which
- // fails the encode ("Invalid Level" on nvenc, "frame MB size > level limit"
- // on libx264) and stalls the session. The output height matches qcap.MaxHeight
- // when the source is downscaled, otherwise probe.Height; the output width is
- // the source width scaled by the same factor (the filter chain preserves AR).
- // qcap + maxH were resolved once at the top (shared with the F4 gate).
+ // 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)
+ // fails libx264 with "frame MB size > level limit" and emits unplayable
+ // segments. The output height matches qcap.MaxHeight when the source is
+ // downscaled, otherwise probe.Height (already populated by ffprobe).
+ qcap := resolveQualityCap(cfg.Quality)
outputHeight := qcap.MaxHeight
if outputHeight == 0 {
outputHeight = cfg.Transcode.MaxHeight
@@ -1750,11 +859,7 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if outputHeight == 0 || (probe.Height > 0 && probe.Height < outputHeight) {
outputHeight = probe.Height
}
- outputWidth := probe.Width
- if probe.Height > 0 && outputHeight != probe.Height {
- outputWidth = int(math.Round(float64(probe.Width) * float64(outputHeight) / float64(probe.Height)))
- }
- args = append(args, "-profile:v", "main", "-level:v", H264LevelForFrame(outputWidth, outputHeight))
+ args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(outputHeight))
// Bitrate must match the level libx264 actually picks for outputHeight,
// not the qcap target for the user's requested label. If a user asks for
@@ -1773,31 +878,7 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if bitrate == "" {
bitrate = "5M"
}
- // Rate control: capped constant-quality where the encoder supports it well
- // (libx264 CRF, NVENC CQ), plain CBR-ish elsewhere. Constant quality is the
- // on-the-fly analogue of per-title encoding: easy scenes (dialogue, anime
- // flats) emit FAR fewer bits than the fixed target — which is what keeps a
- // funnel/LTE link from stalling — while complex scenes can still use up to
- // `-maxrate` (the same ceiling as before, so worst-case quality and the
- // level-derived VBV pair are unchanged). `-bufsize 2×maxrate` gives the VBV
- // a standard one-segment window to absorb spikes; the old 1× window forced
- // the encoder to flatline at the cap. CPB stays far below every H.264
- // level's limit (level 3.1 allows 14 Mbps CPB vs our 3M at 480p).
- switch codec {
- case "libx264":
- // Capped CRF: no -b:v (CRF drives quality), -maxrate/-bufsize cap it.
- args = append(args, "-crf", "23", "-maxrate", bitrate, "-bufsize", doubleBitrate(bitrate))
- case "h264_nvenc":
- // NVENC constant-quality VBR: -cq targets quality, -b:v 0 disables the
- // default 2M average-bitrate target that would otherwise fight it.
- args = append(args, "-cq", "23", "-b:v", "0", "-maxrate", bitrate, "-bufsize", doubleBitrate(bitrate))
- default:
- // QSV / VideoToolbox / VAAPI: keep the proven fixed-bitrate triple —
- // their constant-quality knobs (ICQ, -q:v) have vendor-specific gotchas
- // (VideoToolbox ignores -q:v when -b:v is set; QSV ICQ conflicts with
- // look_ahead=0) and we can't regression-test them here.
- args = append(args, "-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bitrate)
- }
+ args = append(args, "-b:v", bitrate, "-maxrate", bitrate, "-bufsize", bitrate)
// Force keyframe alignment with segment boundaries.
args = append(args, "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSegmentDuration))
@@ -1810,87 +891,20 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
// emit the exact computed width — which can be odd (e.g. 853×480) and
// libx264 then refuses to open. We chain a second `scale=trunc(iw/2)*2:...`
// after the cap to guarantee even dimensions before format/setparams.
- // (maxH was resolved once at the top, shared with the F4 cuda-scale gate.)
- // 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 = ""
+ maxH := qcap.MaxHeight
+ if maxH == 0 {
+ maxH = cfg.Transcode.MaxHeight
}
- // HDR→SDR tonemap, after the scale (downscale-first = fewer pixels to map).
- // Prefer libplacebo (GPU, ONE pass — it also emits the BT.709 colorspace +
- // 8-bit format, so it REPLACES the format/setparams tail); else the zscale
- // CPU chain; else play untonemapped (desaturated, last resort). Skip
- // libplacebo on VAAPI: its Vulkan surface flow doesn't compose with our
- // nv12+hwupload path, so VAAPI keeps the zscale-or-none behaviour.
- //
- // Gate on a real HW encoder (HWAccel != none): only then is the Vulkan
- // device a genuine GPU. A software-only host with mesa would expose lavapipe
- // (CPU Vulkan), which the functional probe accepts but whose tonemap is
- // SLOWER than the zscale CPU chain — so on those hosts libplacebo would be a
- // regression. No HW encoder ⇒ stay on zscale.
- useLibplacebo := probe.HDR != "" && cfg.Transcode.HasLibplacebo &&
- codec != "h264_vaapi" && cfg.Transcode.HWAccel != HWAccelNone
- tonemap := ""
- if probe.HDR != "" && cfg.Transcode.TonemapHDR && !useLibplacebo {
- tonemap = hdrTonemapChain
- }
- // videoTail = everything after the scale: either libplacebo (tonemap +
- // colorspace + format in one) or the (optional zscale) tonemap then the
- // format + color-metadata tail. No leading comma — the scale chain ends in one.
- videoTail := tonemap + "format=" + pixFormat + colorTail
- if useLibplacebo {
- videoTail = libplaceboTonemapFilter
- }
- // Core video chain (scale + tonemap/format tail), WITHOUT the optional
- // hwUploadTail — that has to run last, after any subtitle overlay, so it's
- // appended separately below.
- var vchain string
- switch {
- case useCudaScale:
- // F4: scale on the CUDA surface and hand h264_nvenc a yuv420p CUDA frame
- // directly — no CPU `format`/`setparams` tail (the frame never leaves the
- // GPU; nvenc records BT.709 SDR metadata from the source). scale_cuda's
- // `-2` already yields an even width, so the second even-rounding pass the
- // CPU path needs is unnecessary. useCudaScale already implies a real
- // downscale (probe.Height > cudaCap) on an SDR, non-burn-in NVENC source.
- vchain = fmt.Sprintf("scale_cuda=-2:%d:format=yuv420p", maxH)
- case maxH > 0 && probe.Height > maxH:
- vchain = fmt.Sprintf(
- "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,%s",
- maxH, videoTail,
+ var filterChain string
+ if maxH > 0 && probe.Height > maxH {
+ filterChain = fmt.Sprintf(
+ "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
+ maxH,
)
- default:
- vchain = fmt.Sprintf(
- "scale=trunc(iw/2)*2:trunc(ih/2)*2,%s",
- videoTail,
- )
- }
- if burnIdx >= 0 {
- // Burn-in: process the video to its final size + SDR colorspace FIRST,
- // then composite the subtitle. Overlaying SDR PGS/DVB graphics onto a
- // still-HDR (PQ) frame and tonemapping afterwards would crush the
- // subtitle brightness, so the overlay must come after the tonemap. The
- // subtitle canvas is scaled to the processed frame via scale2ref so a
- // PGS/DVB stream authored at any resolution lines up. hwUploadTail
- // (VAAPI) runs last, on the composited frame.
- filterComplex := fmt.Sprintf(
- "[0:v:0]%s[base];[0:s:%d][base]scale2ref[sub][base2];[base2][sub]overlay%s[vout]",
- vchain, burnIdx, hwUploadTail,
- )
- args = append(args, "-filter_complex", filterComplex)
} else {
- args = append(args, "-vf", vchain+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)
// Audio: AAC stereo 48 kHz — broadest browser compatibility.
audioBitrate := cfg.Transcode.AudioBitrate
@@ -1904,13 +918,6 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
"-ac", "2",
)
- // Force constant frame rate. Many MKV rips are slightly variable-frame-rate
- // (or carry irregular PTS); muxed to fMP4 that produces non-monotonic packet
- // durations ("Packet duration is out of range") and uneven segment lengths
- // the player stutters on. CFR resamples to a steady cadence → uniform
- // segments. Near-CFR sources (23.976/24/25) are essentially untouched.
- args = append(args, "-fps_mode", "cfr")
-
// HLS muxer — fmp4 segments with pre-computed segment count.
// `-start_number` slots seg-N.m4s where N matches the segment index in
// the pre-rendered manifest. Each ffmpeg writes its own ffmpeg.m3u8 but
@@ -1932,15 +939,42 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
return args
}
+// extractSubtitles spawns short-lived ffmpeg jobs to convert each text-based
+// subtitle track to WebVTT in parallel. Bitmap subs (PGS, DVB) are skipped —
+// they would require burn-in into the video encode, which is out of scope.
+func (s *HLSSession) extractSubtitles(ctx context.Context) {
+ subsDir := filepath.Join(s.tmpDir, "subs")
+ for i, sub := range s.probe.SubtitleTracks {
+ if !sub.IsTextSubtitle() {
+ continue
+ }
+ out := filepath.Join(subsDir, fmt.Sprintf("sub-%d.vtt", i))
+ args := []string{
+ "-y", "-hide_banner", "-loglevel", "warning",
+ "-i", s.cfg.SourcePath,
+ "-map", fmt.Sprintf("0:s:%d?", i),
+ "-c:s", "webvtt",
+ out,
+ }
+ // Run sequentially to avoid hammering the disk; subtitle extraction
+ // is fast enough that parallelism isn't worth the complexity.
+ cmd := exec.CommandContext(ctx, s.cfg.Transcode.FFmpegPath, args...)
+ if err := cmd.Run(); err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ log.Printf("[hls %s] subtitle %d (%s) extract failed: %v",
+ shortHLSID(s.cfg.SessionID), i, sub.Lang, err)
+ continue
+ }
+ }
+}
+
// ---- Manifest rendering ----
// 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")
@@ -1951,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
}
@@ -1967,150 +1001,68 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
// video variant + every text subtitle as an EXT-X-MEDIA group. Audio is muxed
// into the video segments for the MVP — separate audio renditions can come
// later (they require a second ffmpeg pipeline producing audio-only segments).
-// buildHLSCopyArgs builds the ffmpeg invocation for VideoCopy mode: video
-// stream copied bit-exact (`-c:v copy`, the segments cut at the source's own
-// keyframes), audio copied when already AAC or re-encoded to AAC 192k
-// otherwise, muxed to fMP4 HLS with ffmpeg writing its OWN media playlist
-// (EVENT while running, ENDLIST on completion) with byte-exact EXTINF
-// durations. Validated empirically on the incident source (HEVC Main10 +
-// EAC3 MKV): seg-0 TTFB ~510 ms, valid hvc1+mp4a stream.
-//
-// Deliberate differences from the encode path:
-// - no encoder/preset/bitrate/keyframe flags (nothing is encoded);
-// - `+temp_file` so segments land atomically (write .tmp → rename) and a
-// listed segment is always complete on disk;
-// - playlist type EVENT: the timeline grows as ffmpeg outruns playback
-// (I/O-bound) and players treat it as live-DVR until ENDLIST.
-func buildHLSCopyArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string) []string {
- args := []string{"-y", "-hide_banner", "-loglevel", "warning", "-stats"}
-
- // StartSec is INTENTIONALLY ignored in copy mode: an EVENT playlist whose
- // entries start at position 0 while the fragments carry an offset tfdt
- // (-ss + -output_ts_offset) is exactly the shape iOS's native HLS parser
- // chokes on (observed 2026-06-10: resume at 368s → player error + session
- // re-bootstrap loop on iPhone). Copy always produces from 0 with true
- // absolute PTS — it outruns playback at I/O speed, so the resume point
- // appears in the growing timeline within seconds and the player's own
- // startPosition seek lands normally.
-
- if cfg.SourceURL != "" {
- args = append(args,
- "-reconnect", "1",
- "-reconnect_streamed", "1",
- "-reconnect_delay_max", "5",
- "-rw_timeout", "30000000",
- )
- }
- args = append(args, "-i", cfg.sourceRef())
-
- // Map video + selected audio (same clamping rules as the encode path).
- args = append(args, "-map", "0:v:0")
- audioIdx := cfg.AudioIndex
- if audioIdx < 0 {
- audioIdx = 0
- for i, a := range probe.AudioTracks {
- if a.Default {
- audioIdx = i
- break
- }
- }
- }
- if n := len(probe.AudioTracks); n > 0 && audioIdx >= n {
- log.Printf("[hls %s] audioIndex %d out of range (%d audio track(s)) — using 0:a:0",
- shortHLSID(cfg.SessionID), audioIdx, n)
- audioIdx = 0
- }
- args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx))
-
- // Video: bit-exact copy. HEVC needs the hvc1 tag or Safari/Apple refuses
- // the track (mkv extracts default to hev1).
- args = append(args, "-c:v", "copy")
- if strings.EqualFold(probe.VideoCodec, "hevc") {
- args = append(args, "-tag:v", "hvc1")
- }
-
- // Audio: copy ONLY when the selected track is AAC with ≤2 channels —
- // WebKit/Apple HLS rejects multichannel AAC at the first media segment
- // (observed via the Safari access log: master → index → init → seg-0
- // fetched twice, then silence — every 5.1 movie failed on iPhone while
- // stereo-AAC episodes played). Anything else (non-AAC, or AAC 5.1+) is
- // re-encoded mirroring the encode path exactly: AAC stereo 48k. The
- // original multichannel track stays intact for external players.
- audioCodec := probe.AudioCodec
- audioChannels := 0
- if audioIdx < len(probe.AudioTracks) {
- audioCodec = probe.AudioTracks[audioIdx].Codec
- audioChannels = probe.AudioTracks[audioIdx].Channels
- }
- if strings.EqualFold(audioCodec, "aac") && audioChannels > 0 && audioChannels <= 2 {
- args = append(args, "-c:a", "copy")
- } else {
- args = append(args, "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2")
- }
-
- args = append(args,
- "-f", "hls",
- "-hls_time", strconv.Itoa(hlsSegmentDuration),
- "-hls_playlist_type", "event",
- "-hls_segment_type", "fmp4",
- "-hls_list_size", "0",
- "-hls_flags", "independent_segments+temp_file",
- "-hls_fmp4_init_filename", "init.mp4",
- "-hls_segment_filename", filepath.Join(tmpDir, "video", "seg-%d.m4s"),
- filepath.Join(tmpDir, "video", copyPlaylistName),
- )
- return args
-}
-
-// renderMasterPlaylistCopy builds the master playlist for VideoCopy mode.
-// Unlike the encode master it deliberately OMITS the CODECS attribute: the
-// stream carries the source's codec verbatim (hvc1/avc1/av01 at whatever
-// profile/level the file has) and a wrong hardcoded string makes iOS reject
-// the variant outright, while omission is legal and universally tolerated.
-// Resolution/bandwidth are the source's real values (best-effort).
-func renderMasterPlaylistCopy(probe *StreamProbe) string {
- var b strings.Builder
- b.WriteString("#EXTM3U\n")
- b.WriteString("#EXT-X-VERSION:7\n")
- // BANDWIDTH is advisory (single variant, no ABR) — a height-based
- // estimate of typical source bitrates is plenty.
- bw := 8_000_000
- switch {
- case probe.Height >= 2000:
- bw = 25_000_000
- case probe.Height >= 1000:
- bw = 10_000_000
- case probe.Height >= 700:
- bw = 5_000_000
- }
- b.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d\n", bw, probe.Width, probe.Height))
- b.WriteString("video/index.m3u8\n")
- return b.String()
-}
-
func renderMasterPlaylist(probe *StreamProbe, qualityLabel string) string {
var b strings.Builder
b.WriteString("#EXTM3U\n")
b.WriteString("#EXT-X-VERSION:7\n")
- // Subtitles are no longer embedded as HLS renditions. The web player attaches
- // every TEXT subtitle as an external served on demand by the /sub
- // endpoint (subtitleHandler) — ONE source for direct-play AND HLS that works
- // under native playback and hls.js alike. Embedding them here too would
- // double the captions menu under hls.js, and the native-HLS path (Chrome's
- // "maybe" canPlayType) never surfaced in-manifest SUBTITLES renditions
- // anyway, which is what made subtitles inconsistent. Bitmap subs (PGS/DVB)
- // remain burn-in (no WebVTT form).
+ // Subtitle renditions. We never set DEFAULT=YES or AUTOSELECT=YES on any
+ // rendition: anime files routinely ship a forced "signs only" English
+ // track with cues only every few minutes, and stacking that track plus
+ // the user's locale auto-select produced the "subs broken" report. The
+ // HLS spec also caps DEFAULT to one per GROUP-ID — "none" trivially
+ // satisfies it. Names disambiguate when several tracks share the same
+ // language ("ES", "ES 2", forced suffix).
+ hasSubs := false
+ langCounts := make(map[string]int)
+ for i, s := range probe.SubtitleTracks {
+ if !s.IsTextSubtitle() {
+ continue
+ }
+ hasSubs = true
+ lang := s.Lang
+ if lang == "" {
+ lang = "und"
+ }
+ base := s.Title
+ if base == "" {
+ base = strings.ToUpper(lang)
+ }
+ key := strings.ToLower(base)
+ langCounts[key]++
+ name := base
+ if langCounts[key] > 1 {
+ name = fmt.Sprintf("%s %d", base, langCounts[key])
+ }
+ if s.Forced {
+ name = name + " (forced)"
+ }
+ b.WriteString(fmt.Sprintf(
+ `#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME=%q,LANGUAGE=%q,DEFAULT=NO,AUTOSELECT=NO,FORCED=%s,URI="subs/sub-%d.m3u8"`+"\n",
+ name, lang, ynBool(s.Forced), i,
+ ))
+ }
// Video variant. Bandwidth + resolution are best-effort estimates from probe.
bw := bitrateForQuality(qualityLabel)
w, h := scaledDimensions(probe.Width, probe.Height, qualityHeight(qualityLabel))
codecs := `avc1.4D4028,mp4a.40.2`
- b.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d,CODECS=%q\n", bw, w, h, codecs))
+ streamInf := fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,RESOLUTION=%dx%d,CODECS=%q", bw, w, h, codecs)
+ if hasSubs {
+ streamInf += `,SUBTITLES="subs"`
+ }
+ b.WriteString(streamInf + "\n")
b.WriteString("video/index.m3u8\n")
return b.String()
}
+func ynBool(b bool) string {
+ if b {
+ return "YES"
+ }
+ return "NO"
+}
+
// bitrateForQuality returns a synthetic bandwidth attribute for the master
// playlist's STREAM-INF — only used by ABR logic, which we don't run yet.
func bitrateForQuality(q string) int {
@@ -2173,46 +1125,6 @@ type hlsStderrCapture struct {
const maxStderrBuf = 64 * 1024
-// ffmpeg -stats progress lines look like:
-//
-// frame= 123 fps= 30 q=28.0 size= 456kB time=00:00:08.00 speed=1.05x
-//
-// emitted with a trailing \r (overwrite-in-place), once per ~0.5s. We parse
-// speed=/fps= out of them for live transcode telemetry (F3) and DON'T log them
-// (one per 0.5s would drown the daemon log) — only \n-terminated warning/error
-// lines reach log.Printf below.
-var (
- reFFmpegSpeed = regexp.MustCompile(`speed=\s*([0-9.]+)x`)
- reFFmpegFps = regexp.MustCompile(`fps=\s*([0-9.]+)`)
-)
-
-func parseFFmpegProgress(line string) (speedX, fps float64, ok bool) {
- m := reFFmpegSpeed.FindStringSubmatch(line)
- if m == nil {
- return 0, 0, false
- }
- v, err := strconv.ParseFloat(m[1], 64)
- if err != nil {
- return 0, 0, false
- }
- if fm := reFFmpegFps.FindStringSubmatch(line); fm != nil {
- fps, _ = strconv.ParseFloat(fm[1], 64)
- }
- return v, fps, true
-}
-
-// isInputBoundLine spots ffmpeg stderr that means the SOURCE read failed (slow
-// debrid link, dropped torrent peer, network timeout) rather than the encoder
-// being too slow — so the player names the bottleneck as the link, not the GPU.
-func isInputBoundLine(line string) bool {
- l := strings.ToLower(line)
- return strings.Contains(l, "i/o error") ||
- strings.Contains(l, "connection reset") ||
- strings.Contains(l, "rw_timeout") ||
- strings.Contains(l, "error in the pull function") ||
- strings.Contains(l, "connection timed out")
-}
-
func (c *hlsStderrCapture) Write(p []byte) (int, error) {
// If the incoming chunk alone exceeds the cap (very long unterminated
// line), drop the buffered prefix AND truncate p so a single multi-MB
@@ -2221,33 +1133,20 @@ func (c *hlsStderrCapture) Write(p []byte) (int, error) {
c.buf.Reset()
p = p[len(p)-maxStderrBuf:]
} else if c.buf.Len()+len(p) > maxStderrBuf {
- // Drop the unterminated partial line; we'll resync on the next \r/\n.
+ // Drop the unterminated partial line; we'll resync on the next \n.
c.buf.Reset()
}
c.buf.Write(p)
- // Frame on \r OR \n: ffmpeg's progress line is \r-terminated, warnings are
- // \n-terminated. Parsing progress per-frame keeps the EWMA fresh; logging
- // only the \n lines keeps the log readable.
for {
- s := c.buf.String()
- idx := strings.IndexAny(s, "\r\n")
- if idx < 0 {
+ line, rest, ok := strings.Cut(c.buf.String(), "\n")
+ if !ok {
break
}
- line := strings.TrimSpace(s[:idx])
c.buf.Reset()
- c.buf.WriteString(s[idx+1:])
- if line == "" {
- continue
+ c.buf.WriteString(rest)
+ if line = strings.TrimSpace(line); line != "" {
+ log.Printf("[hls %s] ffmpeg: %s", shortHLSID(c.owner.cfg.SessionID), line)
}
- if speedX, fps, ok := parseFFmpegProgress(line); ok {
- c.owner.recordProgress(speedX, fps)
- continue // progress line — telemetry only, never logged
- }
- if isInputBoundLine(line) {
- c.owner.markInputBound()
- }
- log.Printf("[hls %s] ffmpeg: %s", shortHLSID(c.owner.cfg.SessionID), line)
}
return len(p), nil
}
diff --git a/internal/engine/hls_cache.go b/internal/engine/hls_cache.go
deleted file mode 100644
index ffff97f..0000000
--- a/internal/engine/hls_cache.go
+++ /dev/null
@@ -1,419 +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, burnSubtitleIndex int) string {
- abs, err := filepath.Abs(sourcePath)
- if err != nil {
- abs = sourcePath
- }
- h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d", abs, quality, audioIndex, burnSubtitleIndex)))
- return hex.EncodeToString(h[:8]) // 16 hex chars — collision-safe enough for per-host cache
-}
-
-// KeyForID derives a cache key from a caller-supplied stable identity instead
-// of a filesystem path (hueco #2 / 2b). Used for debrid HLS-from-URL sessions:
-// the debrid direct URL is re-resolved per play and would never cache-hit, so
-// we key by the torrent info_hash — the same content always maps to the same
-// key across plays. NOT run through filepath.Abs (an id/URL is not a path).
-func (c *HLSCache) KeyForID(id, quality string, audioIndex, burnSubtitleIndex int) string {
- h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d", id, quality, audioIndex, burnSubtitleIndex)))
- return hex.EncodeToString(h[:8])
-}
-
-// 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 ab403a8..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, -1)
- 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 b6e8768..0000000
--- a/internal/engine/hls_cache_test.go
+++ /dev/null
@@ -1,364 +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, -1)
- k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0, -1)
- if k1 != k2 {
- t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
- }
- if c.KeyFor("/a/b/movie.mkv", "720p", 0, -1) == k1 {
- t.Fatal("quality should change key")
- }
- if c.KeyFor("/a/b/movie.mkv", "1080p", 1, -1) == k1 {
- t.Fatal("audio index should change key")
- }
- if c.KeyFor("/a/b/movie.mkv", "1080p", 0, 2) == k1 {
- t.Fatal("burn subtitle index should change key")
- }
- if c.KeyFor("/x/y/other.mkv", "1080p", 0, -1) == 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_copy_smoke_test.go b/internal/engine/hls_copy_smoke_test.go
deleted file mode 100644
index df15eb4..0000000
--- a/internal/engine/hls_copy_smoke_test.go
+++ /dev/null
@@ -1,288 +0,0 @@
-//go:build smoke
-
-package engine
-
-import (
- "context"
- "fmt"
- "net/http/httptest"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "testing"
- "time"
-)
-
-// HLS-copy integration suite — real ffmpeg, synthetic sources replicating
-// every shape that broke the progressive-remux path in production:
-//
-// h264+aac mkv → video copy + audio copy
-// h264+ac3 mkv → video copy + audio re-encode (the priming-dts class
-// that needed delay_moov on the old remux)
-// hevc10+eac3 mkv → the exact "Hoppers" incident shape (Main10, hvc1 tag)
-// resume (-ss) → StartSec mid-file, timeline offset
-//
-// Asserts on every run: ffmpeg's playlist reaches ENDLIST, EXTINF sum ≈
-// source duration, every listed segment exists non-empty, ffprobe decodes
-// the served playlist with the EXPECTED codecs, and the video stream was
-// NOT re-encoded (copy must preserve the source codec).
-//
-// go test -tags=smoke -run TestHLSCopy -v ./internal/engine/
-func copyTestRuntime(t *testing.T) TranscodeRuntime {
- t.Helper()
- 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)
- }
- return TranscodeRuntime{FFmpegPath: ffmpeg, FFprobePath: ffprobe}
-}
-
-// genSource synthesises a test file. encV/encA are the SOURCE encoders; skip
-// the test when the local ffmpeg lacks them (libx265 is optional in some
-// builds).
-func genSource(t *testing.T, rt TranscodeRuntime, name string, vArgs, aArgs []string, durSec int) string {
- t.Helper()
- out := filepath.Join(t.TempDir(), name)
- args := []string{
- "-y", "-loglevel", "error",
- "-f", "lavfi", "-i", fmt.Sprintf("testsrc2=duration=%d:size=640x360:rate=30", durSec),
- "-f", "lavfi", "-i", fmt.Sprintf("sine=frequency=440:duration=%d", durSec),
- }
- args = append(args, vArgs...)
- args = append(args, aArgs...)
- // Short GOP so the copy cuts several segments even on a short source.
- args = append(args, "-g", "60", "-keyint_min", "60", out)
- if outB, err := exec.Command(rt.FFmpegPath, args...).CombinedOutput(); err != nil {
- if strings.Contains(string(outB), "Unknown encoder") {
- t.Skipf("source encoder unavailable: %s", string(outB))
- }
- t.Fatalf("generate %s: %v\n%s", name, err, outB)
- }
- return out
-}
-
-// runCopySession starts a VideoCopy session and waits for ffmpeg's playlist
-// to reach ENDLIST. Returns the session and the final playlist text.
-func runCopySession(t *testing.T, rt TranscodeRuntime, source string, startSec float64) (*HLSSession, string) {
- t.Helper()
- s, err := StartHLSSession(context.Background(), HLSSessionConfig{
- SessionID: "copytest" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 10),
- SourcePath: source,
- FileName: filepath.Base(source),
- AudioIndex: -1,
- StartSec: startSec,
- VideoCopy: true,
- Transcode: rt,
- })
- if err != nil {
- t.Fatalf("StartHLSSession(copy): %v", err)
- }
- t.Cleanup(func() { _ = s.Close() })
-
- playlistPath := filepath.Join(s.tmpDir, "video", copyPlaylistName)
- deadline := time.Now().Add(30 * time.Second)
- for {
- data, err := os.ReadFile(playlistPath)
- if err == nil && strings.Contains(string(data), "#EXT-X-ENDLIST") {
- return s, string(data)
- }
- if time.Now().After(deadline) {
- t.Fatalf("playlist never reached ENDLIST; last read err=%v contents:\n%s", err, string(data))
- }
- time.Sleep(100 * time.Millisecond)
- }
-}
-
-// assertCopyOutput validates playlist structure, segment files, and (via
-// ffprobe over the playlist) that the served stream carries the expected
-// codecs — wantVideo MUST equal the source codec, proving no re-encode.
-func assertCopyOutput(t *testing.T, rt TranscodeRuntime, s *HLSSession, playlist, wantVideo, wantAudio string, wantDur float64) {
- t.Helper()
- if !strings.Contains(playlist, "#EXT-X-PLAYLIST-TYPE:EVENT") {
- t.Errorf("playlist missing EVENT type:\n%s", playlist)
- }
- if !strings.Contains(playlist, `#EXT-X-MAP:URI="init.mp4"`) {
- t.Errorf("playlist missing EXT-X-MAP init.mp4")
- }
-
- var sum float64
- segs := 0
- for _, line := range strings.Split(playlist, "\n") {
- line = strings.TrimSpace(line)
- if strings.HasPrefix(line, "#EXTINF:") {
- v := strings.TrimSuffix(strings.TrimPrefix(line, "#EXTINF:"), ",")
- d, err := strconv.ParseFloat(v, 64)
- if err != nil {
- t.Fatalf("bad EXTINF %q: %v", line, err)
- }
- sum += d
- } else if strings.HasSuffix(line, ".m4s") {
- segs++
- fi, err := os.Stat(filepath.Join(s.tmpDir, "video", line))
- if err != nil || fi.Size() == 0 {
- t.Errorf("listed segment %s missing/empty: %v", line, err)
- }
- }
- }
- if segs == 0 {
- t.Fatalf("no segments listed:\n%s", playlist)
- }
- if sum < wantDur-1.5 || sum > wantDur+1.5 {
- t.Errorf("EXTINF sum = %.2fs, want ≈%.2fs (±1.5)", sum, wantDur)
- }
-
- // ffprobe over the playlist = a real demuxer consuming init + segments.
- out, err := exec.Command(rt.FFprobePath, "-v", "error",
- "-show_entries", "stream=codec_type,codec_name",
- "-of", "csv=p=0",
- filepath.Join(s.tmpDir, "video", copyPlaylistName)).CombinedOutput()
- if err != nil {
- t.Fatalf("ffprobe playlist: %v\n%s", err, out)
- }
- probeStr := string(out)
- if !strings.Contains(probeStr, wantVideo+",video") && !strings.Contains(probeStr, "video,"+wantVideo) &&
- !strings.Contains(probeStr, wantVideo) {
- t.Errorf("video codec: probe=%q want %q (copy must NOT re-encode)", probeStr, wantVideo)
- }
- if !strings.Contains(probeStr, wantAudio) {
- t.Errorf("audio codec: probe=%q want %q", probeStr, wantAudio)
- }
-}
-
-func TestHLSCopy_H264AacCopyBoth(t *testing.T) {
- rt := copyTestRuntime(t)
- src := genSource(t, rt, "h264aac.mkv",
- []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
- []string{"-c:a", "aac", "-b:a", "128k"}, 8)
- s, pl := runCopySession(t, rt, src, 0)
- assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
- // Audio already AAC → the args must COPY it, not re-encode.
- args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
- if !containsSeq(args, "-c:a", "copy") {
- t.Errorf("expected -c:a copy for AAC source, args: %v", args)
- }
-}
-
-func TestHLSCopy_H264Ac3TranscodesAudio(t *testing.T) {
- rt := copyTestRuntime(t)
- src := genSource(t, rt, "h264ac3.mkv",
- []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
- []string{"-c:a", "ac3", "-b:a", "192k"}, 8)
- s, pl := runCopySession(t, rt, src, 0)
- // The re-encoded AAC track starts with a priming dts — the exact shape
- // that produced a malformed init on the old progressive remux. The HLS
- // muxer must land a probe-clean stream regardless.
- assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
- args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
- if !containsSeq(args, "-c:a", "aac") {
- t.Errorf("expected -c:a aac for AC3 source, args: %v", args)
- }
- // MUST downmix to stereo: 6-channel ffmpeg-native AAC is rejected by
- // WebKit/Apple HLS at the first media segment (every 5.1 movie failed on
- // iPhone while stereo-AAC sources played — confirmed via Safari access log).
- if !containsSeq(args, "-ac", "2") {
- t.Errorf("expected -ac 2 (stereo downmix) for re-encoded audio, args: %v", args)
- }
-}
-
-func TestHLSCopy_Hevc10Eac3_IncidentShape(t *testing.T) {
- rt := copyTestRuntime(t)
- src := genSource(t, rt, "hevc10eac3.mkv",
- []string{"-c:v", "libx265", "-preset", "ultrafast", "-pix_fmt", "yuv420p10le", "-x265-params", "log-level=error"},
- []string{"-c:a", "eac3", "-b:a", "192k"}, 8)
- s, pl := runCopySession(t, rt, src, 0)
- assertCopyOutput(t, rt, s, pl, "hevc", "aac", 8)
- // HEVC must carry the hvc1 tag or Safari refuses the track.
- args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
- if !containsSeq(args, "-tag:v", "hvc1") {
- t.Errorf("expected -tag:v hvc1 for HEVC source, args: %v", args)
- }
-}
-
-func TestHLSCopy_Aac51MustReencode(t *testing.T) {
- // AAC is NOT copy-safe when multichannel: WebKit rejects 6-channel AAC at
- // the first media segment exactly like re-encoded 5.1. Source AAC 5.1 →
- // must re-encode to stereo, never copy.
- rt := copyTestRuntime(t)
- src := genSource(t, rt, "aac51.mkv",
- []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
- []string{"-c:a", "aac", "-ac", "6", "-b:a", "256k"}, 8)
- s, pl := runCopySession(t, rt, src, 0)
- assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
- args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
- if containsSeq(args, "-c:a", "copy") {
- t.Errorf("AAC 5.1 must NOT be copied (WebKit rejects multichannel AAC), args: %v", args)
- }
- if !containsSeq(args, "-ac", "2") {
- t.Errorf("AAC 5.1 must re-encode to stereo, args: %v", args)
- }
-}
-
-func TestHLSCopy_ResumeStartSec(t *testing.T) {
- rt := copyTestRuntime(t)
- src := genSource(t, rt, "resume.mkv",
- []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
- []string{"-c:a", "aac", "-b:a", "128k"}, 12)
- _, pl := runCopySession(t, rt, src, 6)
- // StartSec must be IGNORED in copy mode: the playlist covers the FULL
- // timeline from 0 (an offset EVENT playlist breaks iOS's native parser;
- // the player seeks to the resume point itself). Sum ≈ full 12s.
- var sum float64
- for _, line := range strings.Split(pl, "\n") {
- if strings.HasPrefix(line, "#EXTINF:") {
- v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), "#EXTINF:"), ",")
- d, _ := strconv.ParseFloat(v, 64)
- sum += d
- }
- }
- if sum < 10.5 || sum > 13.5 {
- t.Errorf("copy EXTINF sum = %.2fs, want ≈12s (StartSec ignored, full timeline)", sum)
- }
-}
-
-func TestHLSCopy_ServeVideoPlaylistFromDisk(t *testing.T) {
- rt := copyTestRuntime(t)
- src := genSource(t, rt, "serve.mkv",
- []string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
- []string{"-c:a", "aac", "-b:a", "128k"}, 6)
- s, _ := runCopySession(t, rt, src, 0)
-
- rec := httptest.NewRecorder()
- req := httptest.NewRequest("GET", "/hls/x/video/index.m3u8", nil)
- s.ServeVideoPlaylist(rec, req)
- if rec.Code != 200 {
- t.Fatalf("ServeVideoPlaylist = %d, want 200", rec.Code)
- }
- body := rec.Body.String()
- if !strings.Contains(body, "#EXT-X-ENDLIST") || !strings.Contains(body, "seg-0.m4s") {
- t.Errorf("served playlist incomplete:\n%s", body)
- }
- if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.apple.mpegurl" {
- t.Errorf("Content-Type = %q", ct)
- }
-
- // Master: no CODECS attr (a wrong hardcoded string makes iOS reject the
- // variant; omission is legal), real resolution present.
- master := s.MasterPlaylist()
- if strings.Contains(master, "CODECS") {
- t.Errorf("copy master must omit CODECS:\n%s", master)
- }
- if !strings.Contains(master, "RESOLUTION=640x360") {
- t.Errorf("copy master missing real resolution:\n%s", master)
- }
-}
-
-func containsSeq(args []string, a, b string) bool {
- for i := 0; i < len(args)-1; i++ {
- if args[i] == a && args[i+1] == b {
- return true
- }
- }
- return false
-}
diff --git a/internal/engine/hls_cudascale_test.go b/internal/engine/hls_cudascale_test.go
deleted file mode 100644
index 83b8105..0000000
--- a/internal/engine/hls_cudascale_test.go
+++ /dev/null
@@ -1,122 +0,0 @@
-package engine
-
-import (
- "strings"
- "testing"
-)
-
-// F4: buildHLSFFmpegArgsAt must use the full-GPU scale_cuda path ONLY for an
-// SDR NVENC downscale with no burn-in on a host that probed scale_cuda — and
-// keep the CPU `scale=` path for every case that needs CPU frames (HDR tonemap,
-// burn-in, no downscale, non-NVENC, or scale_cuda unavailable).
-
-func nvencCfg(quality string, burn *int) HLSSessionConfig {
- return HLSSessionConfig{
- SessionID: "test-cudascale",
- SourcePath: "/tmp/in.mkv",
- Quality: quality,
- AudioIndex: -1,
- BurnSubtitleIndex: burn,
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- HWAccel: HWAccelNVENC,
- HasScaleCuda: true,
- HasLibplacebo: true,
- TonemapHDR: true,
- },
- }
-}
-
-func argsFor(cfg HLSSessionConfig, probe *StreamProbe) string {
- return strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
-}
-
-func TestCudaScale_SDRDownscale_UsesGPU(t *testing.T) {
- probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100} // SDR (HDR == "")
- got := argsFor(nvencCfg("1080p", nil), probe)
- if !strings.Contains(got, "scale_cuda=-2:1080") {
- t.Errorf("expected scale_cuda for SDR NVENC downscale; got:\n%s", got)
- }
- if !strings.Contains(got, "-hwaccel_output_format cuda") {
- t.Errorf("expected -hwaccel_output_format cuda; got:\n%s", got)
- }
- if strings.Contains(got, "scale=-2:1080") {
- t.Errorf("CPU scale must NOT appear on the cuda path; got:\n%s", got)
- }
-}
-
-func TestCudaScale_HDR_StaysOnCPU(t *testing.T) {
- probe := &StreamProbe{Width: 3840, Height: 2160, HDR: "HDR10", DurationSec: 100}
- got := argsFor(nvencCfg("1080p", nil), probe)
- if strings.Contains(got, "scale_cuda") {
- t.Errorf("HDR must NOT use scale_cuda (needs the tonemap on CPU frames); got:\n%s", got)
- }
- if strings.Contains(got, "-hwaccel_output_format cuda") {
- t.Errorf("HDR must NOT pin frames to CUDA; got:\n%s", got)
- }
- if !strings.Contains(got, "libplacebo") {
- t.Errorf("HDR should still tonemap via libplacebo; got:\n%s", got)
- }
-}
-
-func TestCudaScale_BurnIn_StaysOnCPU(t *testing.T) {
- idx := 0
- probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
- got := argsFor(nvencCfg("1080p", &idx), probe)
- if strings.Contains(got, "scale_cuda") {
- t.Errorf("burn-in requested must NOT use scale_cuda (overlay runs on CPU frames); got:\n%s", got)
- }
-}
-
-func TestCudaScale_NoDownscale_StaysOnCPU(t *testing.T) {
- // Source already at/below the cap → no downscale → no point pinning to CUDA.
- probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
- got := argsFor(nvencCfg("1080p", nil), probe)
- if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
- t.Errorf("no downscale must NOT use the cuda scale path; got:\n%s", got)
- }
-}
-
-func TestCudaScale_ProbeAbsent_StaysOnCPU(t *testing.T) {
- cfg := nvencCfg("1080p", nil)
- cfg.Transcode.HasScaleCuda = false // probe said no / non-CUDA host
- probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
- got := argsFor(cfg, probe)
- if strings.Contains(got, "scale_cuda") {
- t.Errorf("scale_cuda unavailable must fall back to CPU scale; got:\n%s", got)
- }
- if !strings.Contains(got, "scale=-2:1080") {
- t.Errorf("expected CPU scale fallback; got:\n%s", got)
- }
-}
-
-func TestCudaScale_Software_StaysOnCPU(t *testing.T) {
- cfg := nvencCfg("1080p", nil)
- cfg.Transcode.HWAccel = HWAccelNone // libx264, no CUDA decode
- probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
- got := argsFor(cfg, probe)
- if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
- t.Errorf("software encoder must NOT use the cuda scale path; got:\n%s", got)
- }
-}
-
-func TestCudaScale_QSV_StaysOnCPU(t *testing.T) {
- // A non-NVENC HW encoder (HW decode, but not h264_nvenc/cuda) must keep the
- // CPU scale — scale_cuda is NVIDIA-only. Distinct from the software case.
- cfg := nvencCfg("1080p", nil)
- cfg.Transcode.HWAccel = HWAccelQSV
- probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
- got := argsFor(cfg, probe)
- if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
- t.Errorf("QSV must NOT use the cuda scale path; got:\n%s", got)
- }
-}
-
-func TestCudaScale_OriginalQuality_StaysOnCPU(t *testing.T) {
- // "original" → no height cap (maxH == 0) → no downscale → no cuda path.
- probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
- got := argsFor(nvencCfg("original", nil), probe)
- if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
- t.Errorf("original quality (no cap) must NOT use the cuda scale path; got:\n%s", got)
- }
-}
diff --git a/internal/engine/hls_progress_test.go b/internal/engine/hls_progress_test.go
deleted file mode 100644
index f2639f6..0000000
--- a/internal/engine/hls_progress_test.go
+++ /dev/null
@@ -1,103 +0,0 @@
-package engine
-
-import (
- "math"
- "testing"
-)
-
-func TestParseFFmpegProgress(t *testing.T) {
- cases := []struct {
- name string
- line string
- wantSpeed float64
- wantFps float64
- wantOK bool
- }{
- {"realtime", "frame= 123 fps= 30 q=28.0 size= 456kB time=00:00:08.00 bitrate=467.0kbits/s speed=1.05x", 1.05, 30, true},
- {"slow", "frame= 12 fps=2.4 q=-1.0 size= 40kB time=00:00:00.40 speed=0.18x", 0.18, 2.4, true},
- {"tight_spacing", "speed=2x", 2, 0, true},
- {"no_speed", "[libplacebo @ 0x55] Spent 2657ms on a slow shader", 0, 0, false},
- {"warning_line", "[hevc @ 0x7f] Could not find ref with POC 12", 0, 0, false},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- sp, fps, ok := parseFFmpegProgress(c.line)
- if ok != c.wantOK {
- t.Fatalf("ok=%v want %v", ok, c.wantOK)
- }
- if !ok {
- return
- }
- if math.Abs(sp-c.wantSpeed) > 1e-9 {
- t.Errorf("speed=%v want %v", sp, c.wantSpeed)
- }
- if math.Abs(fps-c.wantFps) > 1e-9 {
- t.Errorf("fps=%v want %v", fps, c.wantFps)
- }
- })
- }
-}
-
-func TestIsInputBoundLine(t *testing.T) {
- bound := []string{
- "[http @ 0x55] HTTP error: Connection reset by peer",
- "rw_timeout reached, aborting",
- "Error in the pull function.",
- "tcp://: I/O error",
- }
- for _, l := range bound {
- if !isInputBoundLine(l) {
- t.Errorf("expected input-bound: %q", l)
- }
- }
- notBound := []string{
- "frame= 1 fps=30 speed=1.0x",
- "[libplacebo] slow shader",
- }
- for _, l := range notBound {
- if isInputBoundLine(l) {
- t.Errorf("expected NOT input-bound: %q", l)
- }
- }
-}
-
-// hlsStderrCapture must frame on \r (progress) as well as \n (warnings),
-// fold progress into the EWMA, and surface a sustained slow encode as < 1.0x.
-func TestHlsStderrCaptureProgressEWMA(t *testing.T) {
- s := &HLSSession{}
- s.cfg.SessionID = "test-session-00000000"
- c := &hlsStderrCapture{owner: s}
-
- // Cold-start frames ffmpeg emits while the pipeline fills — must be skipped
- // (hlsStatsWarmupSkip) so they don't drag the EWMA into a false struggle.
- warmup := "frame=0 fps=0 speed=0.01x\r" +
- "frame=0 fps=0 speed=0.04x\r"
- // A burst of \r-terminated steady-state progress lines, like real ffmpeg.
- chunk := "frame=1 fps=2 speed=0.20x\r" +
- "frame=2 fps=2 speed=0.21x\r" +
- "frame=3 fps=2 speed=0.19x\r" +
- "frame=4 fps=2 speed=0.20x\r" +
- "frame=5 fps=2 speed=0.20x\r"
- if _, err := c.Write([]byte(warmup + chunk)); err != nil {
- t.Fatal(err)
- }
- st := s.GetTranscodeStats()
- // 7 progress lines written, first hlsStatsWarmupSkip(2) discarded → 5 counted.
- if st.Samples != 5 {
- t.Fatalf("samples=%d want 5 (7 lines - 2 warmup)", st.Samples)
- }
- if st.SpeedX > 0.5 || st.SpeedX < 0.1 {
- t.Errorf("speedX EWMA=%v, want ~0.2 (sustained slow encode)", st.SpeedX)
- }
- if st.InputBound {
- t.Error("not input-bound for a pure slow encode")
- }
-
- // A \n-terminated I/O error line flips input-bound.
- if _, err := c.Write([]byte("tcp://: I/O error\n")); err != nil {
- t.Fatal(err)
- }
- if !s.GetTranscodeStats().InputBound {
- t.Error("expected input-bound after I/O error line")
- }
-}
diff --git a/internal/engine/hls_ratecontrol_test.go b/internal/engine/hls_ratecontrol_test.go
deleted file mode 100644
index 9a1cc19..0000000
--- a/internal/engine/hls_ratecontrol_test.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package engine
-
-import (
- "strings"
- "testing"
-)
-
-func TestDoubleBitrate(t *testing.T) {
- cases := map[string]string{
- "6000k": "12000k",
- "25000k": "50000k",
- "1500k": "3000k",
- "5M": "10M",
- "1.5M": "3M",
- "2.5m": "5m",
- "800000": "1600000",
- "": "",
- "garbage": "garbage", // unparseable → unchanged (1× bufsize fallback)
- "-5M": "-5M", // non-positive → unchanged
- }
- for in, want := range cases {
- if got := doubleBitrate(in); got != want {
- t.Errorf("doubleBitrate(%q) = %q, want %q", in, got, want)
- }
- }
-}
-
-// segmentIdxForTime must be the exact inverse of segmentStartSec so the
-// resume-aware first spawn (HLSSessionConfig.StartSec) lands on the same
-// segment the player's hls.js startPosition will request.
-func TestSegmentIdxForTime(t *testing.T) {
- cases := map[float64]int{
- 0: 0,
- -3: 0,
- 0.5: 0,
- 1.99: 0,
- 2: 1,
- 3.9: 1,
- 60: 30,
- 3599.9: 1799,
- }
- for sec, want := range cases {
- if got := segmentIdxForTime(sec); got != want {
- t.Errorf("segmentIdxForTime(%v) = %d, want %d", sec, got, want)
- }
- }
- // Round-trip: the start time of the segment we resolve must never be
- // AFTER the requested position (the player would miss its first frames).
- for _, sec := range []float64{0, 1, 2, 7.3, 119.9, 4321} {
- idx := segmentIdxForTime(sec)
- if start := segmentStartSec(idx); start > sec {
- t.Errorf("segmentStartSec(segmentIdxForTime(%v)) = %v > %v", sec, start, sec)
- }
- }
-}
-
-// Capped constant-quality rate control: libx264 gets -crf (no -b:v), NVENC
-// gets -cq with -b:v 0, both keep -maxrate at the level-coherent cap and a
-// 2× -bufsize. VAAPI (and the other vendor encoders) keep the proven
-// fixed-bitrate triple untouched.
-func TestBuildHLSFFmpegArgsRateControl(t *testing.T) {
- probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
- base := HLSSessionConfig{
- SessionID: "test",
- SourcePath: "/media/Movie.mkv",
- Quality: "1080p",
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- FFprobePath: "/usr/bin/ffprobe",
- },
- }
-
- t.Run("libx264 capped CRF", func(t *testing.T) {
- cfg := base
- cfg.Transcode.HWAccel = HWAccelNone
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
- for _, want := range []string{"-crf 23", "-maxrate 6000k", "-bufsize 12000k"} {
- if !strings.Contains(got, want) {
- t.Errorf("libx264 argv missing %q\n%s", want, got)
- }
- }
- if strings.Contains(got, "-b:v 6000k") {
- t.Errorf("libx264 argv must not carry -b:v alongside -crf\n%s", got)
- }
- })
-
- t.Run("nvenc constant-quality VBR", func(t *testing.T) {
- cfg := base
- cfg.Transcode.HWAccel = HWAccelNVENC
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
- // -forced-idr 1 is load-bearing: without it NVENC emits the forced
- // keyframes as non-IDR and every HLS segment stretches to the full
- // GOP, desyncing the playlist timeline (subs/seeks).
- for _, want := range []string{"-rc vbr", "-cq 23", "-b:v 0", "-maxrate 6000k", "-bufsize 12000k", "-forced-idr 1"} {
- if !strings.Contains(got, want) {
- t.Errorf("nvenc argv missing %q\n%s", want, got)
- }
- }
- })
-
- t.Run("qsv keeps bitrate + forced_idr", func(t *testing.T) {
- cfg := base
- cfg.Transcode.HWAccel = HWAccelQSV
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
- // -forced_idr 1 (QSV's spelling): same non-IDR forced-keyframe failure
- // mode as NVENC — without it segments stretch to the full GOP.
- for _, want := range []string{"-look_ahead 0", "-forced_idr 1", "-b:v 6000k"} {
- if !strings.Contains(got, want) {
- t.Errorf("qsv argv missing %q\n%s", want, got)
- }
- }
- })
-
- t.Run("vaapi keeps fixed-bitrate triple", func(t *testing.T) {
- cfg := base
- cfg.Transcode.HWAccel = HWAccelVAAPI
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
- for _, want := range []string{"-b:v 6000k", "-maxrate 6000k", "-bufsize 6000k"} {
- if !strings.Contains(got, want) {
- t.Errorf("vaapi argv missing %q\n%s", want, got)
- }
- }
- if strings.Contains(got, "-crf") || strings.Contains(got, "-cq") {
- t.Errorf("vaapi argv must not carry constant-quality flags\n%s", got)
- }
- })
-}
diff --git a/internal/engine/hls_registry_prewarm_test.go b/internal/engine/hls_registry_prewarm_test.go
deleted file mode 100644
index 6111285..0000000
--- a/internal/engine/hls_registry_prewarm_test.go
+++ /dev/null
@@ -1,80 +0,0 @@
-package engine
-
-import "testing"
-
-// bare session: no ffmpeg, no tmpdir — exercises pure registry semantics.
-func bareSession(id string, prewarm bool, exited bool) *HLSSession {
- s := &HLSSession{cfg: HLSSessionConfig{SessionID: id, Prewarm: prewarm}}
- s.exited = exited
- return s
-}
-
-// A prewarm registered via RegisterKeep must NOT evict the viewer's live
-// session (the old Register-for-everything path killed the stream being
-// watched when the next-episode prewarm got claimed mid-playback).
-func TestRegisterKeepDoesNotEvict(t *testing.T) {
- r := NewHLSSessionRegistry()
- live := bareSession("live", false, false)
- r.Register(live)
-
- pre := bareSession("pre", true, false)
- r.RegisterKeep(pre)
-
- if r.Get("live") == nil {
- t.Fatal("RegisterKeep evicted the live session")
- }
- if r.Get("pre") == nil {
- t.Fatal("RegisterKeep did not register the prewarm")
- }
- if live.isClosed() {
- t.Fatal("RegisterKeep closed the live session")
- }
-
- // A REAL session via Register still evicts everything (single viewer).
- real2 := bareSession("real2", false, false)
- r.Register(real2)
- if r.Get("live") != nil || r.Get("pre") != nil {
- t.Fatal("Register must evict every other session")
- }
- if !live.isClosed() || !pre.isClosed() {
- t.Fatal("Register must close the evicted sessions")
- }
-}
-
-func TestCloseWherePrewarmsOnly(t *testing.T) {
- r := NewHLSSessionRegistry()
- live := bareSession("live", false, false)
- pre1 := bareSession("pre1", true, false)
- pre2 := bareSession("pre2", true, true)
- r.Register(live)
- r.RegisterKeep(pre1)
- r.RegisterKeep(pre2)
-
- n := r.CloseWhere(func(s *HLSSession) bool { return s.IsPrewarm() })
- if n != 2 {
- t.Fatalf("CloseWhere closed %d sessions, want 2", n)
- }
- if r.Get("live") == nil || live.isClosed() {
- t.Fatal("CloseWhere must not touch the live session")
- }
- if r.Get("pre1") != nil || r.Get("pre2") != nil {
- t.Fatal("CloseWhere must remove the prewarms from the registry")
- }
-}
-
-func TestHasLiveEncode(t *testing.T) {
- r := NewHLSSessionRegistry()
- if r.HasLiveEncode() {
- t.Fatal("empty registry must report no live encode")
- }
- done := bareSession("done", false, true) // encode finished / cache HIT
- r.Register(done)
- if r.HasLiveEncode() {
- t.Fatal("an exited encode must not count as live")
- }
- running := bareSession("running", true, false)
- r.RegisterKeep(running)
- if !r.HasLiveEncode() {
- t.Fatal("a running encode must count as live")
- }
-}
diff --git a/internal/engine/hls_test.go b/internal/engine/hls_test.go
index 5b19374..7c7cfa4 100644
--- a/internal/engine/hls_test.go
+++ b/internal/engine/hls_test.go
@@ -7,6 +7,15 @@ import (
"time"
)
+func TestYnBool(t *testing.T) {
+ if got := ynBool(true); got != "YES" {
+ t.Errorf("ynBool(true) = %q, want YES", got)
+ }
+ if got := ynBool(false); got != "NO" {
+ t.Errorf("ynBool(false) = %q, want NO", got)
+ }
+}
+
func TestBitrateForQuality(t *testing.T) {
cases := map[string]int{
"2160p": 25_000_000,
@@ -106,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)
}
}
@@ -135,15 +143,17 @@ func TestRenderMasterPlaylist(t *testing.T) {
if !strings.Contains(out, "RESOLUTION=1920x1080") {
t.Errorf("expected 1920x1080 resolution, got:\n%s", out)
}
- // Subtitles are NO LONGER embedded as HLS renditions — the web player
- // attaches them as external s (served by /sub). The master playlist
- // must therefore carry no SUBTITLES group, no EXT-X-MEDIA, and no SUBTITLES
- // attribute on the video variant, even when the source has text subs.
- if strings.Contains(out, "SUBTITLES") {
- t.Errorf("subtitles must NOT be embedded in the manifest (served as external ), got:\n%s", out)
+ if !strings.Contains(out, `SUBTITLES="subs"`) {
+ t.Errorf("expected subtitles group attached, got:\n%s", out)
}
- if strings.Contains(out, "EXT-X-MEDIA") {
- t.Errorf("no EXT-X-MEDIA rendition expected, got:\n%s", out)
+ if !strings.Contains(out, `LANGUAGE="es"`) || !strings.Contains(out, `LANGUAGE="en"`) {
+ t.Errorf("expected text subs included, got:\n%s", out)
+ }
+ if strings.Contains(out, "hdmv_pgs") || strings.Contains(out, `LANGUAGE="ja"`) {
+ t.Errorf("bitmap subs should be excluded, got:\n%s", out)
+ }
+ if !strings.Contains(out, "(forced)") {
+ t.Errorf("expected forced suffix on English track, got:\n%s", out)
}
}
diff --git a/internal/engine/hls_url_args_test.go b/internal/engine/hls_url_args_test.go
deleted file mode 100644
index b20c18a..0000000
--- a/internal/engine/hls_url_args_test.go
+++ /dev/null
@@ -1,230 +0,0 @@
-package engine
-
-import (
- "strings"
- "testing"
-)
-
-// hueco #2 / 2b — buildHLSFFmpegArgsAt must feed a debrid URL straight to
-// ffmpeg's -i with HTTP-resilience flags, and must NOT add those flags for a
-// local file.
-func TestBuildHLSFFmpegArgsFromURL(t *testing.T) {
- const url = "https://cdn.debrid.it/dl/abc/Movie.mkv"
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourceURL: url,
- CacheID: "deadbeef",
- Quality: "720p",
- 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{
- "-reconnect 1",
- "-reconnect_streamed 1",
- "-reconnect_delay_max 5",
- "-rw_timeout 30000000",
- "-i " + url,
- } {
- if !strings.Contains(got, want) {
- t.Errorf("URL argv missing %q\n%s", want, got)
- }
- }
-}
-
-// A seek (startSec>0) on a URL source must keep BOTH the -ss input seek AND the
-// HTTP-resilience flags, so a seek-restart re-opens the URL with a Range request
-// instead of re-downloading from zero. (-ss before -i = input seek.)
-func TestBuildHLSFFmpegArgsFromURLWithSeek(t *testing.T) {
- const url = "https://cdn.debrid.it/dl/abc/Movie.mkv"
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourceURL: url,
- CacheID: "deadbeef",
- Quality: "720p",
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- FFprobePath: "/usr/bin/ffprobe",
- HWAccel: HWAccelNone,
- },
- }
- probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 5, 30), " ")
-
- for _, want := range []string{
- "-ss 30.000", // input seek before -i
- "-reconnect 1", // resilience flags still present on a restart
- "-rw_timeout 30000000",
- "-i " + url,
- "-output_ts_offset 30.000", // PTS shift so the manifest numbering holds
- } {
- if !strings.Contains(got, want) {
- t.Errorf("seek+URL argv missing %q\n%s", want, got)
- }
- }
- // -ss must come before -i (fast input seek, not slow output seek).
- if strings.Index(got, "-ss 30.000") > strings.Index(got, "-i "+url) {
- t.Errorf("-ss must precede -i for input seek:\n%s", got)
- }
-}
-
-func TestBuildHLSFFmpegArgsLocalNoNetworkFlags(t *testing.T) {
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourcePath: "/tmp/test.mkv",
- Quality: "720p",
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- FFprobePath: "/usr/bin/ffprobe",
- HWAccel: HWAccelNone,
- },
- }
- probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
-
- if strings.Contains(got, "-reconnect") || strings.Contains(got, "-rw_timeout") {
- t.Errorf("local source must not carry HTTP-resilience flags: %s", got)
- }
- if !strings.Contains(got, "-i /tmp/test.mkv") {
- t.Errorf("local argv missing -i /tmp/test.mkv: %s", got)
- }
-}
-
-// sourceRef + cache-key identity: a URL session keys by CacheID, a local one by
-// path. Guards the "re-plays of the same debrid content hit cache despite the
-// URL changing" invariant.
-func TestHLSSourceRefAndCacheID(t *testing.T) {
- urlCfg := HLSSessionConfig{SourceURL: "https://cdn/x.mkv", CacheID: "hash1"}
- if urlCfg.sourceRef() != "https://cdn/x.mkv" {
- t.Errorf("sourceRef = %q, want the URL", urlCfg.sourceRef())
- }
- localCfg := HLSSessionConfig{SourcePath: "/m/x.mkv"}
- if localCfg.sourceRef() != "/m/x.mkv" {
- t.Errorf("sourceRef = %q, want the path", localCfg.sourceRef())
- }
-
- c := &HLSCache{root: "/tmp/cache"}
- // Same CacheID + quality + audio → same key regardless of the (volatile) URL.
- k1 := c.KeyForID("hash1", "720p", -1, -1)
- k2 := c.KeyForID("hash1", "720p", -1, -1)
- if k1 != k2 {
- t.Errorf("KeyForID not stable: %q != %q", k1, k2)
- }
- if c.KeyForID("hash2", "720p", -1, -1) == k1 {
- t.Error("KeyForID collision across distinct ids")
- }
-}
-
-// Burn-in: a bitmap subtitle index routes the video through -filter_complex with
-// scale2ref + overlay and maps [vout]; a nil / text / out-of-range index keeps
-// the plain -vf path (text subs are served as WebVTT, never burned).
-func TestBuildHLSFFmpegArgsBurnSubtitle(t *testing.T) {
- idx := func(n int) *int { return &n }
- base := func() HLSSessionConfig {
- return HLSSessionConfig{
- SessionID: "burn",
- SourcePath: "/tmp/movie.mkv",
- Quality: "1080p",
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- FFprobePath: "/usr/bin/ffprobe",
- HWAccel: HWAccelNone,
- },
- }
- }
- probe := &StreamProbe{
- Width: 1920, Height: 1080, DurationSec: 100,
- SubtitleTracks: []ProbeSubtitleTrack{
- {Index: 0, Codec: "subrip"}, // text → not burnable
- {Index: 1, Codec: "hdmv_pgs_subtitle"}, // bitmap → burnable
- },
- }
-
- t.Run("nil = clean -vf path", func(t *testing.T) {
- got := strings.Join(buildHLSFFmpegArgsAt(base(), probe, "/tmp/d", 0, 0), " ")
- if strings.Contains(got, "-filter_complex") || strings.Contains(got, "overlay") {
- t.Errorf("no-burn argv must not overlay: %s", got)
- }
- if !strings.Contains(got, "-map 0:v:0") || !strings.Contains(got, "-vf") {
- t.Errorf("no-burn argv must -map 0:v:0 with -vf: %s", got)
- }
- })
-
- t.Run("bitmap index burns via filter_complex", func(t *testing.T) {
- cfg := base()
- cfg.BurnSubtitleIndex = idx(1)
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
- for _, want := range []string{"-filter_complex", "[0:s:1]", "scale2ref", "overlay", "-map [vout]"} {
- if !strings.Contains(got, want) {
- t.Errorf("burn argv missing %q: %s", want, got)
- }
- }
- if strings.Contains(got, "-map 0:v:0") {
- t.Errorf("burn argv must map [vout], not 0:v:0: %s", got)
- }
- })
-
- t.Run("text index is ignored (served as WebVTT)", func(t *testing.T) {
- cfg := base()
- cfg.BurnSubtitleIndex = idx(0) // subrip → not a bitmap track
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
- if strings.Contains(got, "overlay") || strings.Contains(got, "-filter_complex") {
- t.Errorf("text-sub burn must fall back to clean encode: %s", got)
- }
- })
-
- t.Run("out-of-range index is ignored", func(t *testing.T) {
- cfg := base()
- cfg.BurnSubtitleIndex = idx(9)
- got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
- if strings.Contains(got, "overlay") {
- t.Errorf("out-of-range burn must fall back to clean encode: %s", got)
- }
- })
-}
-
-// Audio clamp (2026-06-03 no-sound regression): the web persists audioIndex
-// globally, so a stale value from a multi-track file can arrive for a file with
-// fewer tracks. buildHLSFFmpegArgsAt must clamp an out-of-range index to 0:a:0
-// rather than emit `-map 0:a:N?` for a track that doesn't exist — the optional
-// `?` would otherwise silently drop audio and yield a video-only stream.
-func TestBuildHLSFFmpegArgsAudioClamp(t *testing.T) {
- cfg := func(audioIdx int) HLSSessionConfig {
- return HLSSessionConfig{
- SessionID: "audio",
- SourcePath: "/tmp/movie.mkv",
- Quality: "1080p",
- AudioIndex: audioIdx,
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- FFprobePath: "/usr/bin/ffprobe",
- HWAccel: HWAccelNone,
- },
- }
- }
- oneTrack := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100, AudioTracks: []ProbeAudioTrack{{}}}
-
- t.Run("out-of-range index clamps to 0:a:0", func(t *testing.T) {
- got := strings.Join(buildHLSFFmpegArgsAt(cfg(2), oneTrack, "/tmp/d", 0, 0), " ")
- if !strings.Contains(got, "-map 0:a:0?") {
- t.Errorf("out-of-range audioIndex must clamp to 0:a:0?: %s", got)
- }
- if strings.Contains(got, "0:a:2?") {
- t.Errorf("must not map the non-existent 0:a:2: %s", got)
- }
- })
-
- t.Run("in-range index is preserved", func(t *testing.T) {
- twoTracks := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100, AudioTracks: []ProbeAudioTrack{{}, {}}}
- got := strings.Join(buildHLSFFmpegArgsAt(cfg(1), twoTracks, "/tmp/d", 0, 0), " ")
- if !strings.Contains(got, "-map 0:a:1?") {
- t.Errorf("in-range audioIndex 1 must be preserved: %s", got)
- }
- })
-}
diff --git a/internal/engine/hwaccel.go b/internal/engine/hwaccel.go
index 5b5907a..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
@@ -271,60 +160,3 @@ func H264LevelForHeight(height int) string {
return "6.0"
}
}
-
-// h264LevelRank orders level strings so callers can pick the higher of two.
-var h264LevelRank = map[string]int{
- "3.0": 30, "3.1": 31, "3.2": 32,
- "4.0": 40, "4.1": 41, "4.2": 42,
- "5.0": 50, "5.1": 51, "6.0": 60,
-}
-
-// levelForMacroblocks returns the lowest H.264 level whose MaxFS (frame size in
-// macroblocks) covers `mbs`. The height-based H264LevelForHeight tier is correct
-// for 16:9, but anamorphic content (2.39:1 cinemascope) scaled to a given height
-// has a much wider frame: a 2.39:1 source downscaled to 1080 height becomes
-// ~2586×1080 = 11016 MBs, which busts level 4.1's 8192-MB MaxFS. ffmpeg then
-// fails the encode — libx264 with "frame MB size > level limit", h264_nvenc with
-// "InitializeEncoder failed: invalid param (8): Invalid Level" — and emits zero
-// packets (the whole HLS session stalls at "preparando sesión"). MaxFS values
-// from the H.264 spec, Table A-1.
-func levelForMacroblocks(mbs int) string {
- switch {
- case mbs <= 1620:
- return "3.0"
- case mbs <= 3600:
- return "3.1"
- case mbs <= 5120:
- return "3.2"
- case mbs <= 8192: // levels 4.0 and 4.1 share MaxFS 8192; pick 4.1 for headroom
- return "4.1"
- case mbs <= 8704:
- return "4.2"
- case mbs <= 22080:
- return "5.0"
- case mbs <= 36864:
- return "5.1"
- default:
- return "6.0"
- }
-}
-
-// H264LevelForFrame returns the lowest H.264 level that satisfies BOTH the
-// height-derived tier (which carries macroblock-rate / fps headroom) and the
-// actual frame's macroblock count (which catches anamorphic frames that are far
-// wider than 16:9 at a given height). Use this instead of H264LevelForHeight
-// wherever the output width is known — it never under-levels an ultra-wide
-// frame, and for 16:9 content it returns exactly what H264LevelForHeight does.
-func H264LevelForFrame(width, height int) string {
- byHeight := H264LevelForHeight(height)
- if width <= 0 || height <= 0 {
- return byHeight
- }
- // Macroblocks are 16×16; partial blocks at the edge still count (ceil).
- mbs := ((width + 15) / 16) * ((height + 15) / 16)
- byMB := levelForMacroblocks(mbs)
- if h264LevelRank[byMB] > h264LevelRank[byHeight] {
- return byMB
- }
- return byHeight
-}
diff --git a/internal/engine/hwaccel_test.go b/internal/engine/hwaccel_test.go
index 35bb08a..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,152 +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)
- }
- }
-}
-
-func TestH264LevelForFrame(t *testing.T) {
- cases := []struct {
- name string
- width, height int
- want string
- }{
- // 16:9 must match the height-only helper exactly (no regression).
- {"720p 16:9", 1280, 720, "4.0"},
- {"1080p 16:9", 1920, 1080, "4.1"},
- {"1440p 16:9", 2560, 1440, "5.0"},
- {"2160p 16:9", 3840, 2160, "5.1"},
- // Anamorphic 2.39:1 at 1080 height — the regression: ~2586×1080 = 11016
- // MBs busts level 4.1 (8192 MaxFS); must bump to 5.0.
- {"1080h anamorphic 2.39:1", 2586, 1080, "5.0"},
- // Anamorphic 720 height (1728×720 = 4860 MBs) still fits the 4.0 the
- // height floor already picks for fps headroom.
- {"720h anamorphic 2.4:1", 1728, 720, "4.0"},
- // Source 4K anamorphic (3840×1604) encoded at source: 24240 MBs → 5.1.
- {"4K anamorphic source", 3840, 1604, "5.1"},
- // Width unknown → fall back to the height-only tier.
- {"width unknown", 0, 1080, "4.1"},
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- if got := H264LevelForFrame(c.width, c.height); got != c.want {
- t.Errorf("H264LevelForFrame(%d,%d) = %q, want %q", c.width, c.height, got, c.want)
- }
- })
- }
-}
diff --git a/internal/engine/hwscale.go b/internal/engine/hwscale.go
deleted file mode 100644
index 75d64ab..0000000
--- a/internal/engine/hwscale.go
+++ /dev/null
@@ -1,75 +0,0 @@
-package engine
-
-import (
- "context"
- "log"
- "os/exec"
- "strings"
- "sync"
- "time"
-)
-
-// Hardware downscale filter probes (F4). Mirror the libplacebo probe in
-// tonemap.go: presence in `ffmpeg -filters` does NOT prove the filter RUNS —
-// scale_cuda needs a working CUDA runtime + device, which the prod debian-slim
-// image may lack even with the filter compiled in. So we run the real filter on
-// one synthetic frame and require a clean exit, cached per binary.
-
-var (
- scaleCudaCacheMu sync.Mutex
- scaleCudaCache = map[string]bool{}
-)
-
-// FFmpegSupportsScaleCuda reports whether this host can ACTUALLY run scale_cuda
-// — a working CUDA device + the filter compiled in. Used to keep an NVENC
-// downscale fully on the GPU (decode → scale_cuda → h264_nvenc) instead of
-// round-tripping each frame to the CPU for `scale=`, which is the wall on modest
-// GPUs. Fails closed: any error → false → the caller keeps the CPU-scale path
-// (no regression, just no speedup). Cached per path EXCEPT a context timeout,
-// which is transient (a busy box) and must not pin the slow path for the run.
-func FFmpegSupportsScaleCuda(ffmpegPath string) bool {
- if ffmpegPath == "" {
- return false
- }
- scaleCudaCacheMu.Lock()
- if v, ok := scaleCudaCache[ffmpegPath]; ok {
- scaleCudaCacheMu.Unlock()
- return v
- }
- scaleCudaCacheMu.Unlock()
-
- // 10 s: first-run CUDA device creation + filter init can take a beat on a
- // cold/busy box. Probe the WORST-CASE real input: a 10-bit (p010) surface
- // scaled down to 8-bit yuv420p. Most 4K SDR HEVC is Main10, so the gated
- // path routinely hands scale_cuda a 10-bit frame; an 8-bit-only probe would
- // pass on a host whose scale_cuda can't do the 10→8-bit conversion, and the
- // real session would then fail with no CPU fallback. testsrc2 is CPU-side,
- // so format=p010le + hwupload_cuda stands in for a hevc_cuda Main10 decode.
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- out, err := exec.CommandContext(ctx, ffmpegPath,
- "-hide_banner", "-loglevel", "error", "-nostats",
- "-init_hw_device", "cuda=cu:0", "-filter_hw_device", "cu",
- "-f", "lavfi", "-i", "testsrc2=size=256x256:rate=1:duration=1",
- "-vf", "format=p010le,hwupload_cuda,scale_cuda=64:64:format=yuv420p,hwdownload,format=yuv420p",
- "-frames:v", "1", "-f", "null", "-",
- ).CombinedOutput()
- supported := err == nil
-
- // Cache a stable yes/no, but not a transient deadline (see libplacebo probe).
- if supported || ctx.Err() != context.DeadlineExceeded {
- scaleCudaCacheMu.Lock()
- scaleCudaCache[ffmpegPath] = supported
- scaleCudaCacheMu.Unlock()
- }
- if supported {
- log.Printf("[hwscale] ffmpeg scale_cuda works — NVENC SDR downscales stay on the GPU (no CPU round-trip)")
- } else {
- detail := strings.TrimSpace(lastLine(out))
- if detail == "" {
- detail = err.Error()
- }
- log.Printf("[hwscale] ffmpeg scale_cuda unavailable — NVENC keeps the CPU scale path: %v", detail)
- }
- return supported
-}
diff --git a/internal/engine/manager.go b/internal/engine/manager.go
index 66585cd..2a07b6f 100644
--- a/internal/engine/manager.go
+++ b/internal/engine/manager.go
@@ -4,7 +4,6 @@ import (
"context"
"log"
"sync"
- "sync/atomic"
"github.com/torrentclaw/unarr/internal/agent"
)
@@ -34,37 +33,12 @@ type Manager struct {
// Used by the daemon to trigger an immediate sync.
OnTaskDone func()
- // OnStateChange is called after EVERY successful task status transition
- // (resolving → downloading → verifying → organizing → seeding → done/failed),
- // wired by the daemon to trigger an immediate sync so the server sees state
- // changes in near-realtime instead of on the next adaptive tick. Coalesced
- // downstream (TriggerSync is a buffered-1 send), so bursts collapse safely.
- OnStateChange func()
-
// recentlyFinished holds tasks that completed/failed since the last sync read.
// The sync goroutine reads and clears this to include final states in the next sync.
recentMu sync.Mutex
recentFinished []agent.TaskState
-
- // taskStore persists in-flight download payloads so the daemon can re-submit
- // them after a restart (the downloaders resume the partial data). nil = no
- // persistence. shuttingDown gates removal: a task interrupted by a graceful
- // shutdown keeps its store entry (so it resumes), unlike a genuine terminal.
- taskStore taskPersister
- shuttingDown atomic.Bool
}
-// taskPersister is the resume store the manager records in-flight downloads to.
-// Satisfied by *agent.ActiveTaskStore; an interface so tests can inject a fake.
-type taskPersister interface {
- Add(agent.Task)
- Remove(taskID string)
-}
-
-// SetTaskStore wires the resume store. Call once before Submit. Optional —
-// without it, downloads are not persisted for cross-restart resume.
-func (m *Manager) SetTaskStore(s taskPersister) { m.taskStore = s }
-
// NewManager creates a download manager.
func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Downloader) *Manager {
if cfg.MaxConcurrent <= 0 {
@@ -89,35 +63,15 @@ func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Do
// Submit queues a task for download. Non-blocking if capacity available.
func (m *Manager) Submit(ctx context.Context, at agent.Task) {
task := NewTaskFromAgent(at)
- // Event-driven uplink: push every status transition to the server immediately.
- task.SetOnChange(m.OnStateChange)
// Per-task cancellable context so CancelTask can unblock the goroutine
taskCtx, taskCancel := context.WithCancel(ctx)
m.activeMu.Lock()
- // Dedup: a task can arrive twice — once when the daemon re-submits it from
- // the resume store on startup, and again when the web re-dispatches it. The
- // second arrival must NOT launch a parallel goroutine for the same files.
- if _, exists := m.active[task.ID]; exists {
- m.activeMu.Unlock()
- taskCancel()
- log.Printf("[%s] already active — ignoring duplicate submit", agent.ShortID(task.ID))
- return
- }
m.active[task.ID] = task
m.cancels[task.ID] = taskCancel
m.activeMu.Unlock()
- // Persist real downloads so a daemon restart can resume them (torrent via
- // the piece-completion DB, debrid via Range, usenet via its tracker). Stream
- // and seed-file tasks are transient — not resumed. Upgrade downloads
- // (ReplacePath set) are excluded too: re-running one after an interrupted
- // organize could double-download or replace the wrong target.
- if m.taskStore != nil && (at.Mode == "" || at.Mode == "download") && at.ReplacePath == "" {
- m.taskStore.Add(at)
- }
-
m.reporter.Track(task)
// Force start: bypass semaphore (like Transmission's "Force Start")
@@ -222,13 +176,6 @@ func (m *Manager) TaskStates() []agent.TaskState {
// recordFinished stores a completed/failed task for the next sync cycle.
func (m *Manager) recordFinished(update agent.StatusUpdate) {
- // Drop from the resume store on a genuine terminal state (completed / failed
- // / user-cancelled). A shutdown-interrupted task is NOT removed — it stays so
- // the daemon re-submits and resumes it on the next start.
- if m.taskStore != nil && !m.shuttingDown.Load() {
- m.taskStore.Remove(update.TaskID)
- }
-
m.recentMu.Lock()
defer m.recentMu.Unlock()
m.recentFinished = append(m.recentFinished, agent.TaskStateFromUpdate(update))
@@ -324,23 +271,6 @@ func (m *Manager) Wait() {
// Shutdown stops accepting tasks and waits for active downloads to finish.
func (m *Manager) Shutdown(ctx context.Context) {
- // Flag shutdown BEFORE cancelling task contexts: tasks interrupted by the
- // shutdown then keep their resume-store entry (recordFinished skips the
- // removal) so the daemon re-submits and resumes them on the next start.
- m.shuttingDown.Store(true)
-
- // Cancel every task context NOW (before waiting). Downloads block on their
- // context, so this is what actually unblocks them — and because shuttingDown
- // is already set, their recordFinished keeps the resume entry. (Waiting first
- // would just stall until the timeout, and relying on the daemon's outer ctx
- // cancel would race ahead of shuttingDown and wipe the entries.)
- m.activeMu.Lock()
- for id, cancel := range m.cancels {
- cancel()
- delete(m.cancels, id)
- }
- m.activeMu.Unlock()
-
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
@@ -351,7 +281,7 @@ func (m *Manager) Shutdown(ctx context.Context) {
select {
case <-done:
case <-ctx.Done():
- log.Println("shutdown timeout, abandoning active downloads")
+ log.Println("shutdown timeout, cancelling active downloads")
}
// Shutdown all downloaders
@@ -361,7 +291,12 @@ func (m *Manager) Shutdown(ctx context.Context) {
}
}
+ // Clean active map and cancel functions
m.activeMu.Lock()
+ for id, cancel := range m.cancels {
+ cancel()
+ delete(m.cancels, id)
+ }
m.active = make(map[string]*Task)
m.activeMu.Unlock()
}
@@ -409,12 +344,6 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
close(progressCh)
if err != nil {
- // A full disk is terminal — another source would fill the same disk, so
- // skip the fallback and surface the clear message immediately.
- if IsInsufficientDisk(err) {
- m.fail(ctx, task, err.Error())
- return
- }
// Try fallback
if tryFallback(task, m.downloaders) {
log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err)
@@ -457,8 +386,6 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
close(progressCh)
if err != nil {
- // No further fallback here — same disk, same outcome — so an
- // InsufficientDiskError on the fallback surfaces its message directly.
m.fail(ctx, task, err.Error())
return
}
diff --git a/internal/engine/manager_resume_test.go b/internal/engine/manager_resume_test.go
deleted file mode 100644
index c84cbfd..0000000
--- a/internal/engine/manager_resume_test.go
+++ /dev/null
@@ -1,123 +0,0 @@
-package engine
-
-import (
- "context"
- "sync"
- "testing"
- "time"
-
- "github.com/torrentclaw/unarr/internal/agent"
-)
-
-// fakePersister is an in-memory taskPersister for asserting manager↔store calls
-// without touching disk.
-type fakePersister struct {
- mu sync.Mutex
- tasks map[string]bool
-}
-
-func newFakePersister() *fakePersister { return &fakePersister{tasks: map[string]bool{}} }
-func (f *fakePersister) Add(t agent.Task) { f.mu.Lock(); f.tasks[t.ID] = true; f.mu.Unlock() }
-func (f *fakePersister) Remove(id string) { f.mu.Lock(); delete(f.tasks, id); f.mu.Unlock() }
-func (f *fakePersister) has(id string) bool { f.mu.Lock(); defer f.mu.Unlock(); return f.tasks[id] }
-
-func newResumeManager(t *testing.T, p taskPersister) (*Manager, context.Context, context.CancelFunc) {
- t.Helper()
- reporter := NewProgressReporter(agent.NewClient("http://localhost", "test", "test"), time.Hour)
- mgr := NewManager(
- ManagerConfig{MaxConcurrent: 2, OutputDir: t.TempDir()},
- reporter,
- &slowMockDownloader{method: MethodTorrent},
- )
- mgr.SetTaskStore(p)
- ctx, cancel := context.WithCancel(context.Background())
- go reporter.Run(ctx)
- return mgr, ctx, cancel
-}
-
-// dlTask builds a download task. IDs mirror production (UUID-length); the engine
-// logs task.ID[:8] in several places, so sub-8-char ids would panic — not a real
-// case since the web always sends UUIDs.
-func dlTask(id string) agent.Task {
- return agent.Task{
- ID: "task-uuid-" + id, // ≥ 8 chars like a real dispatch id
- InfoHash: "abc123def456abc123def456abc123def456abc1",
- Title: "Resume " + id,
- PreferredMethod: "torrent",
- Mode: "download",
- }
-}
-
-func TestManager_SubmitDedupes(t *testing.T) {
- mgr, ctx, cancel := newResumeManager(t, newFakePersister())
- defer cancel()
-
- task := dlTask("dup-1")
- mgr.Submit(ctx, task)
- mgr.Submit(ctx, task) // duplicate id — must not launch a second download
-
- if n := mgr.ActiveCount(); n != 1 {
- t.Errorf("ActiveCount = %d after duplicate submit, want 1", n)
- }
- cancel()
- mgr.Wait()
-}
-
-func TestManager_PersistsDownloadAndRemovesOnTerminal(t *testing.T) {
- p := newFakePersister()
- mgr, ctx, cancel := newResumeManager(t, p)
- defer cancel()
-
- task := dlTask("t1")
- mgr.Submit(ctx, task)
- if !p.has(task.ID) {
- t.Fatal("download not persisted to the resume store on submit")
- }
-
- // A genuine terminal (user cancel, not shutdown) must remove it.
- mgr.CancelTask(task.ID)
- mgr.Wait()
- if p.has(task.ID) {
- t.Error("task still in resume store after a genuine terminal — should be removed")
- }
-}
-
-func TestManager_KeepsStoreEntryOnShutdown(t *testing.T) {
- p := newFakePersister()
- mgr, ctx, cancel := newResumeManager(t, p)
- defer cancel()
-
- task := dlTask("s1")
- mgr.Submit(ctx, task)
- if !p.has(task.ID) {
- t.Fatal("download not persisted on submit")
- }
-
- // Shutdown interrupts the in-flight download — the entry must SURVIVE so the
- // daemon re-submits and resumes it next start.
- // Shutdown cancels the task contexts itself then waits, so once it returns
- // the interrupted task's recordFinished has run (and must have skipped the
- // removal because shuttingDown is set) — no sleep/poll needed.
- shutCtx, sc := context.WithTimeout(context.Background(), 5*time.Second)
- defer sc()
- mgr.Shutdown(shutCtx)
-
- if !p.has(task.ID) {
- t.Error("task removed from resume store on shutdown — it would not resume")
- }
-}
-
-func TestManager_DoesNotPersistStreamTasks(t *testing.T) {
- p := newFakePersister()
- mgr, ctx, cancel := newResumeManager(t, p)
- defer cancel()
-
- task := dlTask("stream-1")
- task.Mode = "stream"
- mgr.Submit(ctx, task)
- if p.has(task.ID) {
- t.Error("stream task persisted to resume store — only downloads should be")
- }
- cancel()
- mgr.Wait()
-}
diff --git a/internal/engine/probe.go b/internal/engine/probe.go
index 70176e5..930b669 100644
--- a/internal/engine/probe.go
+++ b/internal/engine/probe.go
@@ -50,22 +50,18 @@ type ProbeAudioTrack struct {
// Codec discriminates text (srt/ass/webvtt → extract to WebVTT) vs bitmap
// (pgs/dvbsub → require burn-in).
type ProbeSubtitleTrack struct {
- Index int // 0-based EMBEDDED subtitle stream index (ffmpeg -map 0:s:Index). Unused when External.
+ Index int // 0-based subtitle stream index (ffmpeg -map 0:s:Index)
Lang string // ISO 639-1
Codec string // lowercased — "subrip", "ass", "webvtt", "hdmv_pgs_subtitle", ...
Title string
Forced bool
- // External marks a sidecar file (served via /sub?p=&i=-1) rather than
- // an embedded stream. Path is its absolute filesystem path (External only).
- External bool
- Path string
}
// IsTextSubtitle reports whether a subtitle codec can be extracted to WebVTT
// without re-rendering. Bitmap subs (PGS, DVB) need burn-in.
func (s ProbeSubtitleTrack) IsTextSubtitle() bool {
switch s.Codec {
- case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text":
+ case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text":
return true
}
return false
@@ -92,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)
@@ -138,30 +126,16 @@ func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe,
}
if len(mi.Subtitles) > 0 {
probe.SubtitleTracks = make([]ProbeSubtitleTrack, 0, len(mi.Subtitles))
- // Embedded streams come first (ffprobe order); external sidecars are
- // appended after. Count embedded separately so each embedded track's
- // Index is its true `0:s:N` value regardless of how many externals trail
- // it; externals get Index=-1 and address by Path instead.
- embeddedIdx := 0
- for _, s := range mi.Subtitles {
- t := ProbeSubtitleTrack{
- Lang: s.Lang,
- Codec: strings.ToLower(s.Codec),
- Title: s.Title,
- Forced: s.Forced,
- External: s.External,
- Path: s.Path,
- }
- if s.External {
- t.Index = -1
- } else {
- t.Index = embeddedIdx
- embeddedIdx++
- }
- probe.SubtitleTracks = append(probe.SubtitleTracks, t)
+ for i, s := range mi.Subtitles {
+ probe.SubtitleTracks = append(probe.SubtitleTracks, ProbeSubtitleTrack{
+ Index: i,
+ Lang: s.Lang,
+ Codec: strings.ToLower(s.Codec),
+ Title: s.Title,
+ Forced: s.Forced,
+ })
}
}
- storeProbeCache(filePath, probe)
return probe, nil
}
diff --git a/internal/engine/probe_cache.go b/internal/engine/probe_cache.go
deleted file mode 100644
index fcc7dec..0000000
--- a/internal/engine/probe_cache.go
+++ /dev/null
@@ -1,141 +0,0 @@
-package engine
-
-import (
- "os"
- "sync"
- "time"
-)
-
-// probeCacheTTL is how long a cached probe stays usable. The cache key
-// already incorporates mtime + size, so the TTL is a defense against
-// runaway memory growth from stale paths, not a freshness guarantee — a
-// rename + recreate at the same inode (rare) would still be caught by the
-// mtime delta.
-const probeCacheTTL = 30 * time.Minute
-
-// probeCacheJanitorInterval is how often the background sweeper wakes to
-// drop expired entries. Lookup-time eviction handles hot paths, but a
-// user who browses 5k files and then stops would leak entries until each
-// is individually re-touched. 5 min ≈ 6 sweeps per TTL window — enough
-// to keep memory bounded without burning CPU.
-const probeCacheJanitorInterval = 5 * time.Minute
-
-type probeCacheEntry struct {
- probe *StreamProbe
- expires time.Time
-}
-
-type probeCacheKey struct {
- path string
- mtime int64 // ModTime().UnixNano()
- size int64
-}
-
-var (
- probeCacheMu sync.RWMutex
- probeCache = make(map[probeCacheKey]probeCacheEntry)
- probeCacheJanitor sync.Once
-)
-
-// startProbeCacheJanitor launches the background sweeper exactly once per
-// process. Lazy — fired on first storeProbeCache. Drops expired entries
-// every probeCacheJanitorInterval. Idempotent (sync.Once).
-func startProbeCacheJanitor() {
- probeCacheJanitor.Do(func() {
- go func() {
- ticker := time.NewTicker(probeCacheJanitorInterval)
- defer ticker.Stop()
- for range ticker.C {
- sweepProbeCache(time.Now())
- }
- }()
- })
-}
-
-// sweepProbeCache removes every entry whose expiry is at or before `now`.
-// Exposed for tests; production code calls it indirectly via the janitor
-// goroutine.
-func sweepProbeCache(now time.Time) int {
- probeCacheMu.Lock()
- defer probeCacheMu.Unlock()
- removed := 0
- for k, e := range probeCache {
- if !now.Before(e.expires) {
- delete(probeCache, k)
- removed++
- }
- }
- return removed
-}
-
-// lookupProbeCache returns the cached StreamProbe for the given path if its
-// mtime + size still match the value recorded at insert time, AND the cache
-// entry hasn't expired. Any stat failure / mismatch returns (nil, false) so
-// the caller falls through to a fresh ffprobe run.
-func lookupProbeCache(path string) (*StreamProbe, bool) {
- fi, err := os.Stat(path)
- if err != nil {
- return nil, false
- }
- key := probeCacheKey{
- path: path,
- mtime: fi.ModTime().UnixNano(),
- size: fi.Size(),
- }
- probeCacheMu.RLock()
- entry, ok := probeCache[key]
- probeCacheMu.RUnlock()
- if !ok {
- return nil, false
- }
- if time.Now().After(entry.expires) {
- // Re-check under the write lock so a concurrent re-insert (same key,
- // fresh expiry) isn't accidentally evicted.
- probeCacheMu.Lock()
- if cur, stillThere := probeCache[key]; stillThere && time.Now().After(cur.expires) {
- delete(probeCache, key)
- }
- probeCacheMu.Unlock()
- return nil, false
- }
- return entry.probe, true
-}
-
-// storeProbeCache stashes a fresh probe result under the (path, mtime, size)
-// key. A subsequent ffprobe-skipping HIT requires the file to still have the
-// same mtime + size — anything else (re-encoded, renamed+recreated at the
-// same path, truncated) misses and triggers a re-probe.
-func storeProbeCache(path string, probe *StreamProbe) {
- fi, err := os.Stat(path)
- if err != nil {
- return
- }
- key := probeCacheKey{
- path: path,
- mtime: fi.ModTime().UnixNano(),
- size: fi.Size(),
- }
- probeCacheMu.Lock()
- probeCache[key] = probeCacheEntry{
- probe: probe,
- expires: time.Now().Add(probeCacheTTL),
- }
- probeCacheMu.Unlock()
- // Lazy janitor — fires once per process. No-op after first call.
- startProbeCacheJanitor()
-}
-
-// ResetProbeCache clears the in-memory probe cache. Test-only.
-func ResetProbeCache() {
- probeCacheMu.Lock()
- probeCache = make(map[probeCacheKey]probeCacheEntry)
- probeCacheMu.Unlock()
-}
-
-// ProbeCacheSize returns the number of entries currently cached. Exposed
-// for diagnostics + tests.
-func ProbeCacheSize() int {
- probeCacheMu.RLock()
- defer probeCacheMu.RUnlock()
- return len(probeCache)
-}
diff --git a/internal/engine/probe_cache_test.go b/internal/engine/probe_cache_test.go
deleted file mode 100644
index 76c79da..0000000
--- a/internal/engine/probe_cache_test.go
+++ /dev/null
@@ -1,202 +0,0 @@
-package engine
-
-import (
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestProbeCache_LookupMissNonexistent(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- if _, ok := lookupProbeCache("/path/that/does/not/exist"); ok {
- t.Fatal("expected MISS for non-existent path")
- }
-}
-
-func TestProbeCache_StoreThenLookupHit(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("fake content"), 0o644); err != nil {
- t.Fatalf("write tmp file: %v", err)
- }
-
- probe := &StreamProbe{VideoCodec: "h264", Width: 1920, Height: 1080, DurationSec: 5400}
- storeProbeCache(path, probe)
-
- got, ok := lookupProbeCache(path)
- if !ok {
- t.Fatal("expected HIT after store")
- }
- if got != probe {
- t.Fatalf("expected pointer-identical probe; got different")
- }
-}
-
-func TestProbeCache_MtimeChangeInvalidates(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
- t.Fatalf("write: %v", err)
- }
-
- probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
- storeProbeCache(path, probe)
-
- // Force mtime change. WriteFile doesn't guarantee a different mtime if
- // the filesystem timestamp resolution is coarse, so set it explicitly
- // to a value 1 hour in the future.
- future := time.Now().Add(1 * time.Hour)
- if err := os.Chtimes(path, future, future); err != nil {
- t.Fatalf("chtimes: %v", err)
- }
-
- if _, ok := lookupProbeCache(path); ok {
- t.Fatal("expected MISS after mtime change")
- }
-}
-
-func TestProbeCache_SizeChangeInvalidates(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("aaaaa"), 0o644); err != nil {
- t.Fatalf("write: %v", err)
- }
- originalMtime := time.Now().Add(-1 * time.Hour) // stable, in the past
- if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
- t.Fatalf("chtimes original: %v", err)
- }
-
- probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
- storeProbeCache(path, probe)
-
- // Truncate to a different size, then reset mtime to the original so
- // only `size` differs between store and lookup keys — isolates the
- // size-check path. Without the Chtimes, WriteFile bumps mtime and the
- // test would pass via mtime invalidation regardless of size logic.
- if err := os.WriteFile(path, []byte("a"), 0o644); err != nil {
- t.Fatalf("rewrite: %v", err)
- }
- if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
- t.Fatalf("chtimes restore: %v", err)
- }
-
- if _, ok := lookupProbeCache(path); ok {
- t.Fatal("expected MISS after size change")
- }
-}
-
-func TestProbeCache_ExpiryDropsEntry(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("content"), 0o644); err != nil {
- t.Fatalf("write: %v", err)
- }
-
- // Stash an entry whose expires is already in the past — simulates TTL
- // having elapsed without sleeping for 30 min.
- fi, err := os.Stat(path)
- if err != nil {
- t.Fatalf("stat: %v", err)
- }
- key := probeCacheKey{path: path, mtime: fi.ModTime().UnixNano(), size: fi.Size()}
- probeCacheMu.Lock()
- probeCache[key] = probeCacheEntry{
- probe: &StreamProbe{VideoCodec: "h264"},
- expires: time.Now().Add(-1 * time.Minute),
- }
- probeCacheMu.Unlock()
-
- if _, ok := lookupProbeCache(path); ok {
- t.Fatal("expected MISS for expired entry")
- }
- // Side-effect: lookup should have evicted the stale entry.
- if ProbeCacheSize() != 0 {
- t.Fatalf("expected cache size 0 after expiry eviction; got %d", ProbeCacheSize())
- }
-}
-
-func TestProbeCache_ResetClears(t *testing.T) {
- ResetProbeCache()
-
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
- t.Fatalf("write: %v", err)
- }
-
- storeProbeCache(path, &StreamProbe{VideoCodec: "h264"})
- if ProbeCacheSize() != 1 {
- t.Fatalf("expected size 1 after store; got %d", ProbeCacheSize())
- }
-
- ResetProbeCache()
- if ProbeCacheSize() != 0 {
- t.Fatalf("expected size 0 after reset; got %d", ProbeCacheSize())
- }
-}
-
-func TestProbeCache_StoreNonexistentNoOp(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- // Store on a non-existent path should silently do nothing (stat fails),
- // not panic, and not poison the cache with a zero key.
- storeProbeCache("/nope/never/exists.mkv", &StreamProbe{VideoCodec: "h264"})
- if ProbeCacheSize() != 0 {
- t.Fatalf("expected 0 entries; got %d", ProbeCacheSize())
- }
-}
-
-func TestProbeCache_SweepDropsExpired(t *testing.T) {
- ResetProbeCache()
- t.Cleanup(ResetProbeCache)
-
- dir := t.TempDir()
- // Two entries: one expired, one fresh.
- expiredPath := filepath.Join(dir, "old.mkv")
- freshPath := filepath.Join(dir, "new.mkv")
- if err := os.WriteFile(expiredPath, []byte("a"), 0o644); err != nil {
- t.Fatalf("write expired: %v", err)
- }
- if err := os.WriteFile(freshPath, []byte("b"), 0o644); err != nil {
- t.Fatalf("write fresh: %v", err)
- }
-
- now := time.Now()
- fiExp, _ := os.Stat(expiredPath)
- fiFresh, _ := os.Stat(freshPath)
-
- probeCacheMu.Lock()
- probeCache[probeCacheKey{path: expiredPath, mtime: fiExp.ModTime().UnixNano(), size: fiExp.Size()}] = probeCacheEntry{
- probe: &StreamProbe{VideoCodec: "h264"},
- expires: now.Add(-1 * time.Minute), // expired
- }
- probeCache[probeCacheKey{path: freshPath, mtime: fiFresh.ModTime().UnixNano(), size: fiFresh.Size()}] = probeCacheEntry{
- probe: &StreamProbe{VideoCodec: "h264"},
- expires: now.Add(10 * time.Minute), // fresh
- }
- probeCacheMu.Unlock()
-
- removed := sweepProbeCache(now)
- if removed != 1 {
- t.Fatalf("expected 1 expired entry removed; got %d", removed)
- }
- if ProbeCacheSize() != 1 {
- t.Fatalf("expected 1 fresh entry kept; got %d", ProbeCacheSize())
- }
-}
diff --git a/internal/engine/progress.go b/internal/engine/progress.go
index e5eefe0..eba8814 100644
--- a/internal/engine/progress.go
+++ b/internal/engine/progress.go
@@ -45,19 +45,10 @@ type ProgressReporter struct {
lastCheckAt time.Time // last time we reported for control-signal polling
}
-// NewProgressReporter creates a reporter that flushes every interval. A nil
-// client yields a local-only reporter that tracks progress for terminal output
-// but never calls the API — used by one-shot `unarr download`, which has no
-// server-side task to report against (its synthetic "oneshot-" id is not a UUID
-// and the /api/internal/agent/status endpoint 400s it). Passing the typed nil
-// straight into the interface field would make it non-nil, so guard explicitly.
+// NewProgressReporter creates a reporter that flushes every interval.
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
- var rep StatusReporter
- if ac != nil {
- rep = ac
- }
return &ProgressReporter{
- reporter: rep,
+ reporter: ac,
interval: interval,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
@@ -117,9 +108,6 @@ func (r *ProgressReporter) Run(ctx context.Context) error {
}
func (r *ProgressReporter) flush(ctx context.Context) {
- if r.reporter == nil {
- return // local-only reporter (one-shot): nothing to send
- }
r.mu.Lock()
tasks := make([]*Task, 0, len(r.latest))
for _, t := range r.latest {
@@ -251,10 +239,6 @@ func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse
// ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
- if r.reporter == nil {
- r.Untrack(task.ID)
- return // local-only reporter (one-shot)
- }
update := task.ToStatusUpdate()
if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
diff --git a/internal/engine/readahead.go b/internal/engine/readahead.go
deleted file mode 100644
index eb48321..0000000
--- a/internal/engine/readahead.go
+++ /dev/null
@@ -1,32 +0,0 @@
-package engine
-
-// Torrent stream readahead sizing.
-//
-// anacrolix's Reader (SetResponsive + SetReadahead) already prioritises the
-// pieces in a window ahead of the read position and re-prioritises on Seek —
-// so the playhead→piece-priority feedback is built in. The problem was the
-// window: a static 5 MiB is only ~1.6s of a 25 Mbps 4K stream, so playback
-// outran the download and stalled. Sizing the window by bitrate (~30s of video)
-// keeps a real buffer ahead of the playhead.
-const (
- readaheadSeconds = 30
- minReadahead = 8 << 20 // 8 MiB
- maxReadahead = 96 << 20 // 96 MiB — cap so a seek doesn't waste a huge fetch
- defaultReadahead = 24 << 20 // 24 MiB — when bitrate is unknown (still ~5x the old 5 MiB)
-)
-
-// dynamicReadahead returns the bytes-ahead window for a torrent reader given the
-// stream's bitrate (bits/sec). Unknown/zero bitrate → a generous default.
-func dynamicReadahead(bitrateBps int64) int64 {
- if bitrateBps <= 0 {
- return defaultReadahead
- }
- ra := bitrateBps / 8 * readaheadSeconds
- if ra < minReadahead {
- return minReadahead
- }
- if ra > maxReadahead {
- return maxReadahead
- }
- return ra
-}
diff --git a/internal/engine/readahead_test.go b/internal/engine/readahead_test.go
deleted file mode 100644
index d469ca8..0000000
--- a/internal/engine/readahead_test.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package engine
-
-import "testing"
-
-func TestDynamicReadahead(t *testing.T) {
- cases := []struct {
- name string
- bitrateBps int64
- want int64
- }{
- {"unknown bitrate → default", 0, defaultReadahead},
- {"negative → default", -1, defaultReadahead},
- {"low bitrate clamps to min", 1_000_000, minReadahead}, // 1 Mbps → ~3.75 MiB < 8 MiB
- {"mid bitrate scales", 5_000_000, 5_000_000 / 8 * readaheadSeconds}, // 5 Mbps → ~18.75 MiB
- {"high bitrate within range", 25_000_000, 25_000_000 / 8 * readaheadSeconds}, // 4K ~25 Mbps → ~93.75 MiB
- {"very high clamps to max", 80_000_000, maxReadahead}, // 80 Mbps → 300 MiB > cap
- }
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- got := dynamicReadahead(c.bitrateBps)
- if got != c.want {
- t.Errorf("dynamicReadahead(%d) = %d, want %d", c.bitrateBps, got, c.want)
- }
- if got < minReadahead && c.bitrateBps > 0 {
- t.Errorf("result %d below min %d", got, minReadahead)
- }
- if got > maxReadahead {
- t.Errorf("result %d above max %d", got, maxReadahead)
- }
- })
- }
-}
-
-func TestDynamicReadahead_BeatsOldStatic(t *testing.T) {
- // The whole point: every result is bigger than the old static 5 MiB that
- // stalled HD/4K.
- const oldStatic = 5 * 1024 * 1024
- for _, b := range []int64{0, 1_000_000, 8_000_000, 25_000_000, 100_000_000} {
- if got := dynamicReadahead(b); got <= oldStatic {
- t.Errorf("dynamicReadahead(%d) = %d, not bigger than the old 5 MiB", b, got)
- }
- }
-}
diff --git a/internal/engine/seed_lifecycle_smoke_test.go b/internal/engine/seed_lifecycle_smoke_test.go
deleted file mode 100644
index 2a17059..0000000
--- a/internal/engine/seed_lifecycle_smoke_test.go
+++ /dev/null
@@ -1,118 +0,0 @@
-//go:build smoke
-
-package engine
-
-import (
- "context"
- "os"
- "path/filepath"
- "testing"
- "time"
-
- "github.com/anacrolix/torrent"
- "github.com/anacrolix/torrent/bencode"
- "github.com/anacrolix/torrent/metainfo"
-)
-
-// TestSeedLifecycleSmoke spins up a real loopback BitTorrent swarm: a seeder
-// client serving a small file, and our TorrentDownloader's client leeching it.
-// Once the leecher completes, the torrent is handed to seedAndDrop with a short
-// SeedTime; the test asserts the lifecycle fires and the handle is dropped
-// (removed from d.active). Exercises the real anacrolix Stats/Drop/ticker path,
-// not mocks. Run with: go test -tags smoke -run TestSeedLifecycleSmoke ./internal/engine/
-func TestSeedLifecycleSmoke(t *testing.T) {
- // --- seeder: a real client serving a 4 MiB file over loopback ---
- seedDir := t.TempDir()
- payload := make([]byte, 4<<20)
- for i := range payload {
- payload[i] = byte(i)
- }
- if err := os.WriteFile(filepath.Join(seedDir, "movie.bin"), payload, 0o644); err != nil {
- t.Fatal(err)
- }
-
- var info metainfo.Info
- info.PieceLength = 256 << 10
- if err := info.BuildFromFilePath(filepath.Join(seedDir, "movie.bin")); err != nil {
- t.Fatalf("build info: %v", err)
- }
- var mi metainfo.MetaInfo
- var err error
- if mi.InfoBytes, err = bencode.Marshal(info); err != nil {
- t.Fatalf("marshal info: %v", err)
- }
-
- scfg := torrent.NewDefaultClientConfig()
- scfg.DataDir = seedDir
- scfg.Seed = true
- scfg.NoDHT = true
- scfg.DisableTrackers = true
- scfg.ListenPort = 0 // random — never collides with the leecher's 42069
- seeder, err := torrent.NewClient(scfg)
- if err != nil {
- t.Fatalf("seeder client: %v", err)
- }
- defer seeder.Close()
- st, err := seeder.AddTorrent(&mi)
- if err != nil {
- t.Fatalf("seeder add: %v", err)
- }
- <-st.GotInfo()
- st.DownloadAll() // verifies the existing pieces so the seeder is "complete"
-
- // --- leecher: our downloader, seeding enabled, very short seed time ---
- leechDir := t.TempDir()
- dl, err := NewTorrentDownloader(TorrentConfig{
- DataDir: leechDir,
- SeedEnabled: true,
- SeedTime: 1 * time.Second, // time target fires fast (no peers pull from us, so ratio stays 0)
- })
- if err != nil {
- t.Fatalf("downloader: %v", err)
- }
- dl.seedCheckInterval = 200 * time.Millisecond // poll fast so the 1s target is noticed promptly
- defer dl.Shutdown(context.Background())
-
- lt, err := dl.client.AddTorrent(&mi)
- if err != nil {
- t.Fatalf("leecher add: %v", err)
- }
- <-lt.GotInfo()
- lt.AddClientPeer(seeder) // loopback peer — no DHT/tracker needed
- lt.DownloadAll()
-
- deadline := time.After(30 * time.Second)
- for lt.BytesMissing() > 0 {
- select {
- case <-deadline:
- t.Fatalf("download did not complete (missing %d bytes)", lt.BytesMissing())
- case <-time.After(100 * time.Millisecond):
- }
- }
- t.Logf("leecher completed %d bytes", lt.BytesCompleted())
-
- // Track it as the daemon would for a seeding torrent, then run the lifecycle.
- const taskID = "smoke-seed-task-0001"
- dl.activeMu.Lock()
- dl.active[taskID] = lt
- dl.activeMu.Unlock()
-
- done := make(chan struct{})
- go func() {
- dl.seedAndDrop(taskID, lt, info.Length)
- close(done)
- }()
-
- select {
- case <-done:
- case <-time.After(10 * time.Second):
- t.Fatal("seedAndDrop did not return within 10s")
- }
-
- dl.activeMu.Lock()
- _, stillTracked := dl.active[taskID]
- dl.activeMu.Unlock()
- if stillTracked {
- t.Error("torrent still tracked after seedAndDrop — lifecycle did not drop it")
- }
-}
diff --git a/internal/engine/stream.go b/internal/engine/stream.go
index d2fe05e..1414f15 100644
--- a/internal/engine/stream.go
+++ b/internal/engine/stream.go
@@ -235,9 +235,7 @@ func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered,
func (s *StreamEngine) NewFileReader(ctx context.Context) io.ReadSeekCloser {
reader := s.file.NewReader()
reader.SetResponsive()
- // Generous default window (vs the old static 5 MiB that stalled HD/4K). This
- // CLI path has no bitrate probe, so dynamicReadahead(0) returns the default.
- reader.SetReadahead(dynamicReadahead(0))
+ reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead
reader.SetContext(ctx)
return reader
}
diff --git a/internal/engine/stream_growing_test.go b/internal/engine/stream_growing_test.go
deleted file mode 100644
index 401e544..0000000
--- a/internal/engine/stream_growing_test.go
+++ /dev/null
@@ -1,222 +0,0 @@
-package engine
-
-import (
- "io"
- "net/http"
- "net/http/httptest"
- "testing"
-)
-
-// fakeGrowing is a GrowingSource backed by a fixed byte slice. When final is
-// true it behaves like a completed remux (ReadAt returns io.EOF at the end);
-// est overrides the advertised estimate (0 = use len(data)).
-type fakeGrowing struct {
- data []byte
- final bool
- est int64
-}
-
-func (f *fakeGrowing) ReadAt(p []byte, off int64) (int, error) {
- if off < 0 || off >= int64(len(f.data)) {
- return 0, io.EOF
- }
- n := copy(p, f.data[off:])
- if int(off)+n >= len(f.data) {
- return n, io.EOF
- }
- return n, nil
-}
-func (f *fakeGrowing) Size() int64 { return int64(len(f.data)) }
-func (f *fakeGrowing) Final() bool { return f.final }
-func (f *fakeGrowing) EstimatedSize() int64 {
- if f.est > 0 {
- return f.est
- }
- return int64(len(f.data))
-}
-func (f *fakeGrowing) FileName() string { return "movie.mp4" }
-func (f *fakeGrowing) Close() error { return nil }
-
-func TestParseByteRange(t *testing.T) {
- cases := []struct {
- in string
- start, end int64
- }{
- {"", 0, -1},
- {"bytes=0-", 0, -1},
- {"bytes=100-", 100, -1},
- {"bytes=5-9", 5, 9},
- {"bytes=0-0", 0, 0},
- {"bytes=10-19,40-49", 10, 19}, // first range only
- {"bytes=-500", 0, -1}, // suffix unsupported → open from 0
- {"garbage", 0, -1},
- {"bytes=", 0, -1},
- }
- for _, c := range cases {
- s, e := parseByteRange(c.in)
- if s != c.start || e != c.end {
- t.Errorf("parseByteRange(%q) = (%d,%d), want (%d,%d)", c.in, s, e, c.start, c.end)
- }
- }
-}
-
-func TestServeGrowing_FinalFullRequest(t *testing.T) {
- data := []byte("0123456789abcdef")
- src := &fakeGrowing{data: data, final: true}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodGet, "/stream", nil)
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- res := rec.Result()
- if res.StatusCode != http.StatusPartialContent {
- t.Fatalf("status = %d, want 206", res.StatusCode)
- }
- if got := res.Header.Get("Content-Range"); got != "bytes 0-15/16" {
- t.Errorf("Content-Range = %q, want bytes 0-15/16", got)
- }
- if got := res.Header.Get("Accept-Ranges"); got != "bytes" {
- t.Errorf("Accept-Ranges = %q, want bytes", got)
- }
- if got := res.Header.Get("Content-Type"); got != "video/mp4" {
- t.Errorf("Content-Type = %q, want video/mp4", got)
- }
- // Final + open-ended → exact Content-Length.
- if got := res.Header.Get("Content-Length"); got != "16" {
- t.Errorf("Content-Length = %q, want 16", got)
- }
- if body := rec.Body.String(); body != string(data) {
- t.Errorf("body = %q, want %q", body, string(data))
- }
-}
-
-func TestServeGrowing_OffsetRange(t *testing.T) {
- data := []byte("0123456789abcdef")
- src := &fakeGrowing{data: data, final: true}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodGet, "/stream", nil)
- req.Header.Set("Range", "bytes=10-")
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- res := rec.Result()
- if res.StatusCode != http.StatusPartialContent {
- t.Fatalf("status = %d, want 206", res.StatusCode)
- }
- if got := res.Header.Get("Content-Range"); got != "bytes 10-15/16" {
- t.Errorf("Content-Range = %q, want bytes 10-15/16", got)
- }
- if body := rec.Body.String(); body != "abcdef" {
- t.Errorf("body = %q, want abcdef", body)
- }
-}
-
-func TestServeGrowing_BoundedRange(t *testing.T) {
- data := []byte("0123456789abcdef")
- src := &fakeGrowing{data: data, final: true}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodGet, "/stream", nil)
- req.Header.Set("Range", "bytes=5-9")
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- res := rec.Result()
- if res.StatusCode != http.StatusPartialContent {
- t.Fatalf("status = %d, want 206", res.StatusCode)
- }
- if got := res.Header.Get("Content-Range"); got != "bytes 5-9/16" {
- t.Errorf("Content-Range = %q, want bytes 5-9/16", got)
- }
- if body := rec.Body.String(); body != "56789" {
- t.Errorf("body = %q, want 56789 (exactly the requested 5 bytes)", body)
- }
-}
-
-func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
- // Not final: only 8 bytes produced, estimate says 100. We advertise the
- // estimate as the total — iOS/WebKit refuses to play a whose
- // "bytes=0-1" probe comes back without a concrete instance length, so "/*"
- // (unknown total) is not an option. The estimate need not be byte-exact;
- // the real re-seek loop was the malformed init segment (fixed by
- // +delay_moov), not the advertised total. Body is what exists so far.
- src := &fakeGrowing{data: []byte("01234567"), final: false, est: 100}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodGet, "/stream", nil)
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- res := rec.Result()
- if res.StatusCode != http.StatusPartialContent {
- t.Fatalf("status = %d, want 206", res.StatusCode)
- }
- if got := res.Header.Get("Content-Range"); got != "bytes 0-99/100" {
- t.Errorf("Content-Range = %q, want bytes 0-99/100 (estimate)", got)
- }
- // Not final → no exact Content-Length (chunked) so we never promise bytes
- // a still-running remux might not produce.
- if got := res.Header.Get("Content-Length"); got != "" {
- t.Errorf("Content-Length = %q, want empty (chunked) while not final", got)
- }
- if body := rec.Body.String(); body != "01234567" {
- t.Errorf("body = %q, want 01234567 (bytes produced so far)", body)
- }
-}
-
-func TestServeGrowing_HeadProbe(t *testing.T) {
- // HEAD: advertise the total (estimate while growing) so iOS gets the size
- // it needs from its probe.
- src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodHead, "/stream", nil)
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- res := rec.Result()
- if res.StatusCode != http.StatusOK {
- t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
- }
- if got := res.Header.Get("Content-Length"); got != "4242" {
- t.Errorf("HEAD Content-Length = %q, want 4242", got)
- }
- if rec.Body.Len() != 0 {
- t.Errorf("HEAD body = %d bytes, want 0", rec.Body.Len())
- }
-}
-
-func TestServeGrowing_ProbeRangeCarriesTotal(t *testing.T) {
- // The iOS "bytes=0-1" probe MUST come back with a concrete instance length
- // (bytes 0-1/), or WebKit bails and re-bootstraps the session.
- src := &fakeGrowing{data: []byte("0123456789"), final: false, est: 6685677633}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodGet, "/stream", nil)
- req.Header.Set("Range", "bytes=0-1")
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- if got := rec.Result().Header.Get("Content-Range"); got != "bytes 0-1/6685677633" {
- t.Errorf("Content-Range = %q, want bytes 0-1/6685677633 (concrete total for iOS)", got)
- }
- if body := rec.Body.String(); body != "01" {
- t.Errorf("body = %q, want 01 (the 2 probed bytes)", body)
- }
-}
-
-func TestServeGrowing_RangeBeyondTotal(t *testing.T) {
- src := &fakeGrowing{data: []byte("0123456789"), final: true}
- ss := &StreamServer{}
-
- req := httptest.NewRequest(http.MethodGet, "/stream", nil)
- req.Header.Set("Range", "bytes=999-")
- rec := httptest.NewRecorder()
- ss.serveGrowing(rec, req, src)
-
- if rec.Result().StatusCode != http.StatusRequestedRangeNotSatisfiable {
- t.Errorf("status = %d, want 416", rec.Result().StatusCode)
- }
-}
diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go
index 4165107..2e182fd 100644
--- a/internal/engine/stream_server.go
+++ b/internal/engine/stream_server.go
@@ -2,15 +2,12 @@ package engine
import (
"context"
- "crypto/tls"
- "encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
- "net/url"
"os"
"os/exec"
"path/filepath"
@@ -22,7 +19,6 @@ import (
"time"
"github.com/anacrolix/torrent"
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// StreamURLs holds all available stream URLs keyed by network type.
@@ -41,47 +37,19 @@ type FileProvider interface {
FileSize() int64
}
-// GrowingSource is a /stream source whose bytes are produced over time by an
-// ffmpeg remux/transcode to a temp file (see transcodeSource). It is served
-// via manual Range handling (serveGrowing) instead of http.ServeContent,
-// which assumes a complete, fixed-size, seekable file. Used by direct-play's
-// remux path (hueco #3 / 3b): mkv h264/aac → progressive fMP4, no re-encode.
-type GrowingSource interface {
- // ReadAt blocks until off+len(p) bytes have been produced, the source is
- // final, or a timeout elapses; near the live edge it returns a short
- // (n>0, nil) read so the caller can stream what exists so far.
- ReadAt(p []byte, off int64) (int, error)
- Size() int64 // bytes produced so far
- Final() bool // ffmpeg exited — Size() is now the true total
- EstimatedSize() int64 // expected final size, for the scrubber timeline
- FileName() string
- Close() error
-}
-
// StreamServer is a persistent HTTP server that serves one file at a time.
// Start it once with Listen(), then swap files with SetFile()/ClearFile().
// The server stays alive for the entire daemon lifecycle — no port churn.
type StreamServer struct {
mu sync.RWMutex
provider FileProvider
- growing GrowingSource // set instead of provider for the progressive-remux path (3b)
- taskID string // current task being streamed
+ taskID string // current task being streamed
- server *http.Server
- port int
- url string // best single URL (backward compat)
- urls StreamURLs // all available URLs by network type
- upnpMapping *UPnPMapping
- httpsUpnpMapping *UPnPMapping // WAN mapping for the direct-TLS HTTPS port
-
- // TLS — optional HTTPS listener for direct, valid-cert browser playback
- // (agent-TLS feature). httpsPort 0 = disabled. tlsCert holds the current
- // server certificate, swapped atomically on renewal; the TLS config reads it
- // via GetCertificate so a renewed cert applies without dropping the listener.
- // HTTP (port) keeps serving regardless — loopback players + the funnel use it.
- httpsPort int
- httpsServer *http.Server
- tlsCert atomic.Pointer[tls.Certificate]
+ server *http.Server
+ port int
+ url string // best single URL (backward compat)
+ urls StreamURLs // all available URLs by network type
+ upnpMapping *UPnPMapping
// enableUPnP gates whether Listen() asks the gateway to publish the
// stream port to the WAN. UPnP is opt-in (false by default) because
// /stream and /hls have no auth — exposing them on the public internet
@@ -97,48 +65,13 @@ type StreamServer struct {
hls *HLSSessionRegistry // HLS sessions served on /hls//...
- // streamSecret signs the per-URL stream tokens (see stream_token.go). In
- // memory only; regenerated each daemon start. requireToken gates whether
- // remote (non-loopback) /stream and /hls requests must carry a valid token.
- streamSecret []byte
- requireToken bool
-
- // ffmpegPath is the resolved ffmpeg binary, used by /thumbnail to extract a
- // single frame on demand. Empty = thumbnails disabled (503). Set once before
- // Listen() via SetFFmpegPath; read-only thereafter so the handler needs no lock.
- ffmpegPath string
-
- // cacheSubtitles / cacheThumbnails enable write-through caching of extracted
- // WebVTT / JPEG frames into the hidden ".unarr" sidecar dir next to the media
- // (mirrors the scan-time prewarm). Set once before Listen() via the setters;
- // default false here, flipped on from config (default true) by the daemon.
- cacheSubtitles bool
- cacheThumbnails bool
-
- // trickplayWidth is the tile width (px) the scan-time prewarm used to build
- // the trickplay sprite (library.trickplay.width). The /trickplay handler keys
- // the sidecar lookup on it so the agent owns the width — the web need not know
- // it. 0 = trickplay disabled (the handler 404s and the web falls back to
- // on-demand /thumbnail). Set once before Listen() via SetTrickplayWidth.
- trickplayWidth int
-
- // resolveMediaPath remaps a web-supplied media path that's unreachable as-is
- // (e.g. a host path /mnt/nas/peliculas/… while this docker agent mounts that
- // media at /downloads) onto the real on-disk path, mirroring the /stream and
- // /hls self-heal. Set by the daemon (which owns the allowed roots + relocate
- // logic); nil = identity. Applied AFTER token verification in the path-scoped
- // handlers (/thumbnail, /trickplay, /sub) so the token still binds the
- // original web path. Read-only after Listen().
- resolveMediaPath func(string) string
-
- lastActivity atomic.Int64
- maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
- totalFileSize atomic.Int64
- bitrateBps atomic.Int64 // video bitrate in bits/sec (from ffprobe, 0 = unknown)
- durationSec atomic.Int64 // video duration in seconds (from ffprobe, 0 = unknown)
- topReaderID atomic.Int64 // ID of the reader that set maxByteOffset (only it can advance it)
- readerCounter atomic.Int64 // monotonic counter for assigning reader IDs
- speedtestActive atomic.Bool // single-flight guard for /speedtest (unauth + public via funnel)
+ lastActivity atomic.Int64
+ maxByteOffset atomic.Int64 // highest sequential read position (main playback connection)
+ totalFileSize atomic.Int64
+ bitrateBps atomic.Int64 // video bitrate in bits/sec (from ffprobe, 0 = unknown)
+ durationSec atomic.Int64 // video duration in seconds (from ffprobe, 0 = unknown)
+ topReaderID atomic.Int64 // ID of the reader that set maxByteOffset (only it can advance it)
+ readerCounter atomic.Int64 // monotonic counter for assigning reader IDs
}
// NewStreamServer creates a stream server bound to the given port.
@@ -150,37 +83,7 @@ type StreamServer struct {
// have no auth, so exposing them to the public internet is something the
// operator must explicitly request.
func NewStreamServer(port int) *StreamServer {
- return &StreamServer{
- port: port,
- hls: NewHLSSessionRegistry(),
- streamSecret: newStreamSecret(),
- requireToken: true, // secure by default; the agent self-mints tokens
- }
-}
-
-// StreamSecretHex returns the daemon's stream-token signing key as hex, so it
-// can be reported to the web (which mints the HLS path token the agent then
-// verifies). Treat as a secret — it lets the holder mint valid stream tokens.
-func (ss *StreamServer) StreamSecretHex() string {
- return hex.EncodeToString(ss.streamSecret)
-}
-
-// SetRequireStreamToken toggles remote stream-token enforcement. Loopback
-// callers are always exempt. Call before Listen() / before reporting URLs.
-// Default is true; an operator can disable it via config for debugging.
-func (ss *StreamServer) SetRequireStreamToken(require bool) {
- ss.requireToken = require
-}
-
-// checkStreamToken reports whether a request may proceed: always true when
-// enforcement is off; otherwise the token must be a valid signature for scope.
-// No loopback exemption — cloudflared relays public funnel traffic over
-// localhost, so loopback is not a trust signal.
-func (ss *StreamServer) checkStreamToken(scope, token string) bool {
- if !ss.requireToken {
- return true
- }
- return verifyStreamToken(ss.streamSecret, scope, token, time.Now())
+ return &StreamServer{port: port, hls: NewHLSSessionRegistry()}
}
// SetUPnPEnabled toggles WAN publishing of the stream port. Call before
@@ -189,89 +92,6 @@ func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
ss.enableUPnP = enabled
}
-// EnableTLS arms the HTTPS listener on httpsPort. Call before Listen(). The
-// listener starts even without a certificate installed yet — handshakes fail
-// until one is set via SetTLSCertificate, so a cert issued asynchronously (the
-// future ACME broker) applies live without a restart. httpsPort <= 0 is a no-op.
-func (ss *StreamServer) EnableTLS(httpsPort int) {
- if httpsPort > 0 {
- ss.httpsPort = httpsPort
- }
-}
-
-// SetTLSCertificate atomically installs or replaces the server certificate used
-// by the HTTPS listener. Safe to call at any time (startup or on renewal); the
-// new cert applies to the next TLS handshake without dropping the listener.
-func (ss *StreamServer) SetTLSCertificate(cert *tls.Certificate) {
- ss.tlsCert.Store(cert)
-}
-
-// LoadTLSCertificateFromFiles reads a PEM cert+key pair from disk and installs
-// it. Returns an error if the pair is missing or invalid — the caller decides
-// whether that's fatal (the daemon treats it as "TLS off, HTTP keeps serving").
-func (ss *StreamServer) LoadTLSCertificateFromFiles(certPath, keyPath string) error {
- cert, err := tls.LoadX509KeyPair(certPath, keyPath)
- if err != nil {
- return fmt.Errorf("load TLS keypair: %w", err)
- }
- ss.SetTLSCertificate(&cert)
- return nil
-}
-
-// HasTLSCertificate reports whether a server certificate is currently installed.
-func (ss *StreamServer) HasTLSCertificate() bool { return ss.tlsCert.Load() != nil }
-
-// HTTPSPort returns the active HTTPS port, or 0 when TLS is disabled.
-func (ss *StreamServer) HTTPSPort() int { return ss.httpsPort }
-
-// SetFFmpegPath sets the ffmpeg binary used by /thumbnail to extract single
-// frames on demand. Call before Listen(); empty leaves thumbnails disabled
-// (the handler returns 503). Read-only after Listen() — no locking in the handler.
-func (ss *StreamServer) SetFFmpegPath(path string) {
- ss.ffmpegPath = path
-}
-
-// SetCacheSubtitles toggles write-through caching of extracted WebVTT into the
-// hidden ".unarr" sidecar dir next to the media file (library.cache_subtitles,
-// default true). Call before Listen(); read-only thereafter.
-func (ss *StreamServer) SetCacheSubtitles(on bool) {
- ss.cacheSubtitles = on
-}
-
-// SetCacheThumbnails toggles write-through caching of extracted JPEG frames into
-// the hidden ".unarr" sidecar dir next to the media file (library.cache_thumbnails,
-// default true). Call before Listen(); read-only thereafter.
-func (ss *StreamServer) SetCacheThumbnails(on bool) {
- ss.cacheThumbnails = on
-}
-
-// SetTrickplayWidth records the tile width used to build the trickplay sprite
-// (library.trickplay.width). 0 leaves trickplay disabled. Call before Listen().
-func (ss *StreamServer) SetTrickplayWidth(width int) {
- ss.trickplayWidth = width
-}
-
-// SetPathResolver installs the media-path self-heal used by the path-scoped
-// handlers (/thumbnail, /trickplay, /sub). fn receives the web-supplied path
-// and returns the real on-disk path, or "" when it can't be located under an
-// allowed root. nil leaves paths untouched. Call before Listen().
-func (ss *StreamServer) SetPathResolver(fn func(string) string) {
- ss.resolveMediaPath = fn
-}
-
-// healMediaPath applies the resolver (if set) to a web-supplied path. Returns
-// the path unchanged when no resolver is installed or it found no better path —
-// the caller's os.Stat then fails as before, preserving the 404 behaviour.
-func (ss *StreamServer) healMediaPath(rawPath string) string {
- if ss.resolveMediaPath == nil {
- return rawPath
- }
- if healed := ss.resolveMediaPath(rawPath); healed != "" {
- return healed
- }
- return rawPath
-}
-
// SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
// ports) is always merged in. Call before Listen().
@@ -302,16 +122,6 @@ func (ss *StreamServer) writeCORSHeaders(w http.ResponseWriter, r *http.Request,
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
- // Private Network Access: an https:// page (public) fetching this agent on a
- // loopback/LAN address (private) triggers a PNA preflight carrying
- // `Access-Control-Request-Private-Network: true`. Without echoing
- // `Allow-Private-Network: true` Chrome blocks the request — so the
- // loopback (127.0.0.1) + LAN-IP direct-play candidates would never connect
- // from the production https player. Only emitted for already-allowlisted
- // origins (above), so it widens nothing beyond the existing CORS trust.
- if r.Header.Get("Access-Control-Request-Private-Network") == "true" {
- w.Header().Set("Access-Control-Allow-Private-Network", "true")
- }
if expose != "" {
w.Header().Set("Access-Control-Expose-Headers", expose)
}
@@ -336,12 +146,8 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler)
mux.HandleFunc("/health", ss.healthHandler)
- mux.HandleFunc("/speedtest", ss.speedtestHandler)
mux.HandleFunc("/playlist.m3u", ss.playlistHandler)
mux.HandleFunc("/hls/", ss.hlsHandler)
- mux.HandleFunc("/thumbnail", ss.thumbnailHandler)
- mux.HandleFunc("/trickplay", ss.trickplayHandler)
- mux.HandleFunc("/sub", ss.subtitleHandler)
// SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart)
lc := net.ListenConfig{
@@ -421,100 +227,15 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
}()
log.Printf("[stream] server listening on port %d", ss.port)
-
- // Optional HTTPS listener (agent-TLS feature). Non-fatal: if it can't bind,
- // HTTP keeps serving so the funnel + LAN HTTP path are unaffected.
- if ss.httpsPort > 0 {
- if err := ss.listenTLS(ctx, mux); err != nil {
- log.Printf("[stream] HTTPS listener disabled: %v", err)
- ss.httpsPort = 0
- }
- }
- return nil
-}
-
-// listenTLS starts the HTTPS listener on ss.httpsPort serving the same mux as
-// the HTTP server. The certificate is read per-handshake from the atomic holder
-// (tlsCert) so a renewed cert applies without restarting the listener; until a
-// cert is installed, handshakes fail cleanly (the HTTP path is unaffected).
-func (ss *StreamServer) listenTLS(ctx context.Context, mux http.Handler) error {
- lc := net.ListenConfig{
- Control: func(network, address string, c syscall.RawConn) error {
- return c.Control(func(fd uintptr) { _ = setReuseAddr(fd) })
- },
- }
-
- var listener net.Listener
- var err error
- basePort := ss.httpsPort
- for attempt := 0; attempt < 10; attempt++ {
- listener, err = lc.Listen(ctx, "tcp", fmt.Sprintf("0.0.0.0:%d", ss.httpsPort))
- if err == nil {
- break
- }
- if !strings.Contains(err.Error(), "address already in use") {
- return fmt.Errorf("https listen on %d: %w", ss.httpsPort, err)
- }
- ss.httpsPort++
- }
- if err != nil {
- return fmt.Errorf("https: all ports busy (%d-%d): %w", basePort, ss.httpsPort, err)
- }
- ss.httpsPort = listener.Addr().(*net.TCPAddr).Port
-
- // Best-effort: publish the HTTPS port to the WAN so a remote browser can hit
- // the per-agent direct-TLS host (https://..agent.unarr.app:)
- // without a manual port-forward. Mapped here — after the actual bound port is
- // known — so the web (which encodes the reported httpsPort) and the router
- // agree. If UPnP/NAT-PMP isn't available the remote path just falls back to
- // the CloudFlare funnel; the LAN direct path is unaffected.
- if ss.enableUPnP {
- if m, err := SetupUPnP(ss.httpsPort); err != nil {
- log.Printf("[stream] HTTPS UPnP failed: %v (remote direct-TLS falls back to the funnel)", err)
- } else {
- ss.httpsUpnpMapping = m
- log.Printf("[stream] HTTPS UPnP: published port %d to WAN via %s:%d", ss.httpsPort, m.ExternalIP, m.ExternalPort)
- }
- }
-
- tlsCfg := &tls.Config{
- MinVersion: tls.VersionTLS12,
- NextProtos: []string{"h2", "http/1.1"},
- GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
- if cert := ss.tlsCert.Load(); cert != nil {
- return cert, nil
- }
- return nil, fmt.Errorf("no TLS certificate installed")
- },
- }
- ss.httpsServer = &http.Server{
- Handler: mux,
- ReadHeaderTimeout: 5 * time.Second,
- TLSConfig: tlsCfg,
- }
-
- go func() {
- // Empty cert/key paths → ServeTLS uses TLSConfig.GetCertificate.
- if err := ss.httpsServer.ServeTLS(listener, "", ""); err != nil && err != http.ErrServerClosed {
- log.Printf("[stream] HTTPS server error: %v", err)
- }
- }()
-
- log.Printf("[stream] HTTPS listening on port %d (certificate installed: %v)", ss.httpsPort, ss.HasTLSCertificate())
return nil
}
// SetFile atomically swaps the file being served and resets progress tracking.
func (ss *StreamServer) SetFile(provider FileProvider, taskID string) {
ss.mu.Lock()
- prevGrowing := ss.growing
ss.provider = provider
- ss.growing = nil // a raw-file provider supersedes any in-flight remux
ss.taskID = taskID
ss.mu.Unlock()
- if prevGrowing != nil {
- _ = prevGrowing.Close() // stop the orphan ffmpeg + drop its temp file
- }
ss.totalFileSize.Store(provider.FileSize())
ss.lastActivity.Store(time.Now().UnixNano())
ss.maxByteOffset.Store(0)
@@ -537,40 +258,12 @@ func (ss *StreamServer) SetFile(provider FileProvider, taskID string) {
}
}
-// SetGrowingFile serves a progressive-remux source on /stream (hueco #3 / 3b):
-// ffmpeg `-c copy` mkv→fMP4 to a growing temp file, range-served via
-// serveGrowing. Supersedes any prior provider/growing source (single-viewer).
-func (ss *StreamServer) SetGrowingFile(src GrowingSource, taskID string) {
- ss.mu.Lock()
- prevGrowing := ss.growing
- ss.growing = src
- ss.provider = nil
- ss.taskID = taskID
- ss.mu.Unlock()
- if prevGrowing != nil {
- _ = prevGrowing.Close()
- }
- ss.totalFileSize.Store(src.EstimatedSize())
- ss.lastActivity.Store(time.Now().UnixNano())
- ss.maxByteOffset.Store(0)
- ss.topReaderID.Store(0)
- // Rate-limit + bitrate tracking are for raw-file playback; the remux pump
- // has its own pacing (ffmpeg copy is I/O-bound), so leave them at zero.
- ss.bitrateBps.Store(0)
- ss.durationSec.Store(0)
-}
-
// ClearFile stops serving any file. Subsequent requests return 404.
func (ss *StreamServer) ClearFile() {
ss.mu.Lock()
ss.provider = nil
- prevGrowing := ss.growing
- ss.growing = nil
ss.taskID = ""
ss.mu.Unlock()
- if prevGrowing != nil {
- _ = prevGrowing.Close()
- }
ss.totalFileSize.Store(0)
ss.maxByteOffset.Store(0)
ss.topReaderID.Store(0)
@@ -585,55 +278,22 @@ func (ss *StreamServer) CurrentTaskID() string {
return ss.taskID
}
-// HasFile returns true if a file (raw provider or growing remux) is being served.
+// HasFile returns true if a file is currently being served.
func (ss *StreamServer) HasFile() bool {
ss.mu.RLock()
defer ss.mu.RUnlock()
- return ss.provider != nil || ss.growing != nil
+ return ss.provider != nil
}
// URL returns the best single stream URL (backward compat).
-// URL returns the best single /stream URL, carrying a `?t=` token when
-// enforcement is on. This is what the one-shot `unarr stream` hands to the
-// player — and since the best URL is the Tailscale/LAN address (not loopback),
-// it must be tokenised or a remote-addressed player would be rejected.
-func (ss *StreamServer) URL() string { return ss.tokenizeStreamURL(ss.url) }
+func (ss *StreamServer) URL() string { return ss.url }
-// tokenizeStreamURL appends a freshly-minted `?t=` (scope "stream") to a
-// /stream URL. No-op when the URL is empty or enforcement is off.
-func (ss *StreamServer) tokenizeStreamURL(u string) string {
- if u == "" || !ss.requireToken {
- return u
- }
- sep := "?"
- if strings.Contains(u, "?") {
- sep = "&"
- }
- return u + sep + "t=" + mintStreamToken(ss.streamSecret, streamScopeStream, time.Now())
-}
-
-// URLsJSON returns all available stream URLs as a JSON string, each carrying a
-// freshly-minted `?t=` stream token when enforcement is on. The web reports
-// these verbatim to the browser (pass-through), so the token reaches the
-// player without any web-side minting.
+// URLsJSON returns all available stream URLs as a JSON string.
func (ss *StreamServer) URLsJSON() string {
- b, _ := json.Marshal(ss.tokenizedStreamURLs())
+ b, _ := json.Marshal(ss.urls)
return string(b)
}
-// tokenizedStreamURLs appends a `?t=` (scope "stream") to each non-empty
-// /stream URL. No-op when enforcement is off.
-func (ss *StreamServer) tokenizedStreamURLs() StreamURLs {
- if !ss.requireToken {
- return ss.urls
- }
- return StreamURLs{
- LAN: ss.tokenizeStreamURL(ss.urls.LAN),
- Tailscale: ss.tokenizeStreamURL(ss.urls.Tailscale),
- Public: ss.tokenizeStreamURL(ss.urls.Public),
- }
-}
-
// Port returns the bound port.
func (ss *StreamServer) Port() int { return ss.port }
@@ -650,15 +310,9 @@ func (ss *StreamServer) IdleSince() time.Duration {
// Call only at daemon shutdown — NOT between file swaps.
func (ss *StreamServer) Shutdown(ctx context.Context) error {
ss.upnpMapping.Remove()
- ss.httpsUpnpMapping.Remove()
if ss.hls != nil {
ss.hls.CloseAll()
}
- if ss.httpsServer != nil {
- if err := ss.httpsServer.Shutdown(ctx); err != nil {
- log.Printf("[stream] HTTPS shutdown: %v", err)
- }
- }
if ss.server != nil {
return ss.server.Shutdown(ctx)
}
@@ -669,21 +323,15 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error {
// The web client picks the first reachable one — same fallback strategy as
// the legacy /stream URLs.
func (ss *StreamServer) hlsBaseURLs(sessionID string) StreamURLs {
- // Token rides as a path segment so the playlists' relative child URIs
- // (video/index.m3u8, seg-N.m4s, subs/…) inherit it via relative resolution.
- base := "/hls/" + sessionID
- if ss.requireToken {
- base += "/" + mintStreamToken(ss.streamSecret, streamScopeHLS(sessionID), time.Now())
- }
var out StreamURLs
if ss.urls.LAN != "" {
- out.LAN = strings.Replace(ss.urls.LAN, "/stream", base, 1)
+ out.LAN = strings.Replace(ss.urls.LAN, "/stream", "/hls/"+sessionID, 1)
}
if ss.urls.Tailscale != "" {
- out.Tailscale = strings.Replace(ss.urls.Tailscale, "/stream", base, 1)
+ out.Tailscale = strings.Replace(ss.urls.Tailscale, "/stream", "/hls/"+sessionID, 1)
}
if ss.urls.Public != "" {
- out.Public = strings.Replace(ss.urls.Public, "/stream", base, 1)
+ out.Public = strings.Replace(ss.urls.Public, "/stream", "/hls/"+sessionID, 1)
}
return out
}
@@ -704,17 +352,11 @@ func (ss *StreamServer) HLSURLsJSON(sessionID string) string {
// video/index.m3u8 — video media playlist
// video/init.mp4 — fMP4 init segment
// video/seg-.m4s — video segment
+// subs/sub-.m3u8 — per-subtitle media playlist (synthesised)
+// subs/sub-.vtt — WebVTT subtitle (extracted by ffmpeg)
func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
ss.lastActivity.Store(time.Now().UnixNano())
- // Debug access log (UNARR_HLS_DEBUG=1): which client fetches which HLS
- // resource. Off by default — a player polls the playlist every few
- // seconds and segments stream constantly, far too chatty for normal logs.
- if os.Getenv("UNARR_HLS_DEBUG") == "1" {
- host, _, _ := net.SplitHostPort(r.RemoteAddr)
- log.Printf("[hls-debug] %s %s from %s UA=%q", r.Method, r.URL.Path, host, r.Header.Get("User-Agent"))
- }
-
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
return
}
@@ -732,36 +374,16 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "hls session not found", http.StatusNotFound)
return
}
- remainder := ""
- if len(parts) > 1 {
- remainder = parts[1]
- }
- // Auth: when enforcement is on, the URL is /hls///.
- // Peel the token segment and verify it (no loopback exemption — funnel
- // traffic arrives over localhost). 404 on mismatch — same response as an
- // unknown session, no oracle.
- if ss.requireToken {
- sub := strings.SplitN(remainder, "/", 2)
- if !verifyStreamToken(ss.streamSecret, streamScopeHLS(sessionID), sub[0], time.Now()) {
- http.Error(w, "hls session not found", http.StatusNotFound)
- return
- }
- if len(sub) < 2 {
- http.Error(w, "missing resource", http.StatusNotFound)
- return
- }
- remainder = sub[1]
- }
session := ss.hls.Get(sessionID)
if session == nil {
http.Error(w, "hls session not found", http.StatusNotFound)
return
}
- if remainder == "" {
+ if len(parts) == 1 {
http.Error(w, "missing resource", http.StatusNotFound)
return
}
- resource := remainder
+ resource := parts[1]
switch {
case resource == "master.m3u8":
@@ -769,9 +391,7 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
case resource == "probe.json":
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-cache")
- info := session.ProbeInfo()
- ss.attachSubtitleVTTURLs(info, session.cfg.sourceRef())
- _ = json.NewEncoder(w).Encode(info)
+ _ = json.NewEncoder(w).Encode(session.ProbeInfo())
case resource == "video/index.m3u8":
session.ServeVideoPlaylist(w, r)
case resource == "video/init.mp4":
@@ -784,14 +404,54 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
return
}
session.ServeSegment(w, r, idx)
+ case strings.HasPrefix(resource, "subs/sub-") && strings.HasSuffix(resource, ".m3u8"):
+ idxStr := strings.TrimSuffix(strings.TrimPrefix(resource, "subs/sub-"), ".m3u8")
+ idx, err := strconv.Atoi(idxStr)
+ if err != nil {
+ http.Error(w, "bad subtitle index", http.StatusBadRequest)
+ return
+ }
+ ss.serveSubtitlePlaylist(w, r, session, idx)
+ case strings.HasPrefix(resource, "subs/sub-") && strings.HasSuffix(resource, ".vtt"):
+ idxStr := strings.TrimSuffix(strings.TrimPrefix(resource, "subs/sub-"), ".vtt")
+ idx, err := strconv.Atoi(idxStr)
+ if err != nil {
+ http.Error(w, "bad subtitle index", http.StatusBadRequest)
+ return
+ }
+ session.ServeSubtitle(w, r, idx)
default:
- // Subtitles are no longer served here — the web player fetches each text
- // track on demand from /sub (subtitleHandler). The master playlist no
- // longer advertises a SUBTITLES group, so no player requests subs/sub-*.
http.Error(w, "unknown hls resource", http.StatusNotFound)
}
}
+// serveSubtitlePlaylist generates a single-VTT-segment HLS playlist on the
+// fly so hls.js can consume it as a regular subtitle rendition. The VTT file
+// itself is extracted asynchronously by HLSSession.extractSubtitles.
+func (ss *StreamServer) serveSubtitlePlaylist(w http.ResponseWriter, r *http.Request, session *HLSSession, idx int) {
+ if idx < 0 || idx >= len(session.probe.SubtitleTracks) {
+ http.Error(w, "subtitle out of range", http.StatusNotFound)
+ return
+ }
+ dur := session.durationSec
+ if dur < 1 {
+ dur = 1
+ }
+ body := strings.Builder{}
+ body.WriteString("#EXTM3U\n")
+ body.WriteString("#EXT-X-VERSION:3\n")
+ body.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
+ body.WriteString(fmt.Sprintf("#EXT-X-TARGETDURATION:%d\n", int(dur)+1))
+ body.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n")
+ body.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", dur))
+ body.WriteString(fmt.Sprintf("sub-%d.vtt\n", idx))
+ body.WriteString("#EXT-X-ENDLIST\n")
+
+ w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
+ w.Header().Set("Cache-Control", "no-cache")
+ _, _ = io.WriteString(w, body.String())
+}
+
// healthHandler responde con el estado del servidor en JSON.
// Útil para diagnosticar conectividad desde redes remotas o Tailscale:
//
@@ -849,66 +509,6 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp) //nolint:errcheck
}
-// speedtestHandler streams a fixed-size, incompressible payload so the web
-// player can measure REAL throughput to THIS agent — the path the stream
-// actually travels (LAN-direct, tailnet, or the CF funnel). The web origin's
-// link tells us nothing about that path; measuring it here is the only honest
-// signal for the pre-play quality suggestion. No auth or active stream needed:
-// the bytes carry no information. CORS-gated like the other endpoints so the
-// cross-origin fetch can read + time the body.
-func (ss *StreamServer) speedtestHandler(w http.ResponseWriter, r *http.Request) {
- ss.lastActivity.Store(time.Now().UnixNano())
- if ss.writeCORSHeaders(w, r, "") {
- return
- }
- // Single-flight: this endpoint is unauthenticated (it carries no data) and
- // reachable over the public cloudflared funnel, so bound the bandwidth a
- // caller can drain — only one measurement runs at a time, a concurrent
- // request gets 429 instead of stacking another multi-MB transfer.
- if !ss.speedtestActive.CompareAndSwap(false, true) {
- http.Error(w, "speedtest busy", http.StatusTooManyRequests)
- return
- }
- defer ss.speedtestActive.Store(false)
- const defaultSize = 2 * 1024 * 1024
- const maxSize = 4 * 1024 * 1024 // matches the web /api/v1/speed-test cap
- size := defaultSize
- if v := r.URL.Query().Get("size"); v != "" {
- if n, err := strconv.Atoi(v); err == nil {
- if n < 64*1024 {
- n = 64 * 1024
- } else if n > maxSize {
- n = maxSize
- }
- size = n
- }
- }
- w.Header().Set("Content-Type", "application/octet-stream")
- w.Header().Set("Content-Length", strconv.Itoa(size))
- w.Header().Set("Cache-Control", "no-store")
- if r.Method == http.MethodHead {
- w.WriteHeader(http.StatusOK)
- return
- }
- // Reuse one non-repeating chunk (incompressible enough that gzip can't skew
- // the measurement) to avoid per-write allocation.
- const chunk = 64 * 1024
- buf := make([]byte, chunk)
- for i := range buf {
- buf[i] = byte((i*31 + 7) & 0xff)
- }
- for remaining := size; remaining > 0; {
- n := chunk
- if remaining < n {
- n = remaining
- }
- if _, err := w.Write(buf[:n]); err != nil {
- return // client disconnected mid-measure
- }
- remaining -= n
- }
-}
-
// playlistHandler generates an M3U playlist for VLC with #EXTVLCOPT language hints.
// Query params: audioLangs (comma-sep), subLangs (comma-sep), resumeSec, title, streamUrl.
// If streamUrl is omitted, uses the current best stream URL.
@@ -934,31 +534,14 @@ func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request)
resumeSec := sanitize(q.Get("resumeSec"))
title := sanitize(q.Get("title"))
streamURL := q.Get("streamUrl")
- // VLC network buffer (ms). The web sends a network-aware value (small on
- // LAN/Tailscale, larger on the CF funnel); clamp to a sane range. Older web
- // clients that don't pass it get a modest default — the old flat 30000 made
- // VLC pre-buffer ~30 s before playback even on a fast, range-served source.
- networkCaching := 3000
- if v := sanitize(q.Get("networkCaching")); v != "" {
- if n, err := strconv.Atoi(v); err == nil {
- if n < 500 {
- n = 500
- } else if n > 60000 {
- n = 60000
- }
- networkCaching = n
- }
- }
// Only accept http(s) URLs to prevent file:// or other URI schemes in the playlist.
if streamURL != "" && !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
streamURL = ""
}
if streamURL == "" {
- // No self-minting fallback: returning a freshly-tokenised URL for a
- // param-less request would make /playlist.m3u an open token oracle
- // (any caller could fetch a valid /stream?t=… here). The web always
- // passes an already-tokenised streamUrl param; the playlist just echoes
- // it — the real auth gate is /stream itself.
+ streamURL = ss.url
+ }
+ if streamURL == "" {
http.Error(w, "no active stream", http.StatusNotFound)
return
}
@@ -978,7 +561,7 @@ func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request)
if resumeSec != "" && resumeSec != "0" {
b.WriteString(fmt.Sprintf("#EXTVLCOPT:start-time=%s\n", resumeSec))
}
- b.WriteString(fmt.Sprintf("#EXTVLCOPT:network-caching=%d\n", networkCaching))
+ b.WriteString("#EXTVLCOPT:network-caching=30000\n")
b.WriteString(streamURL + "\n")
w.Header().Set("Content-Type", "audio/x-mpegurl")
@@ -994,13 +577,12 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
log.Printf("[stream] %s /stream from %s Range:%q", r.Method, clientIP, r.Header.Get("Range"))
- // Get current source (raw provider or growing remux; nil if none).
+ // Get current provider (may be nil if no file is being served)
ss.mu.RLock()
provider := ss.provider
- growing := ss.growing
ss.mu.RUnlock()
- if provider == nil && growing == nil {
+ if provider == nil {
http.Error(w, "no active stream", http.StatusNotFound)
return
}
@@ -1009,21 +591,6 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
return
}
- // Auth: every caller must carry a valid stream token. 404 (not 401/403) so
- // an unauthorised caller gets no oracle that a stream is active here.
- if !ss.checkStreamToken(streamScopeStream, r.URL.Query().Get("t")) {
- log.Printf("[stream] rejected %s — bad/absent token", clientIP)
- http.Error(w, "no active stream", http.StatusNotFound)
- return
- }
-
- // Progressive-remux path (3b): a growing fMP4 produced by ffmpeg `-c copy`.
- // Range-served manually because http.ServeContent needs a complete file.
- if growing != nil {
- ss.serveGrowing(w, r, growing)
- return
- }
-
rawReader := provider.NewFileReader(r.Context())
if rawReader == nil {
http.Error(w, "file not found", http.StatusNotFound)
@@ -1070,571 +637,6 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
http.ServeContent(w, r, provider.FileName(), time.Time{}, reader)
}
-// thumbnailHandler serves ONE JPEG frame decoded from a file at a timestamp.
-// It backs the web's "file characteristics" panel (frames on demand, hueco
-// medio): the panel renders a strip of at several positions, each hitting
-// this route. Independent of the active /stream — no session, no provider, no
-// effect on playback; ffmpeg just seeks the path and emits a single frame.
-//
-// Auth: a token scoped thumb: minted by the web with this agent's
-// stream secret. The path travels in ?p= (already client-visible — the library
-// UI shows it) and the token's scope binds that exact path, so a tampered p
-// fails verification. 404 (not 401/403) on a bad token — no oracle, same as
-// /stream. The path is additionally clamped to a real regular file as
-// defense-in-depth against a (trusted) web bug pointing ffmpeg at a device/FIFO.
-func (ss *StreamServer) thumbnailHandler(w http.ResponseWriter, r *http.Request) {
- ss.lastActivity.Store(time.Now().UnixNano())
- if ss.writeCORSHeaders(w, r, "") {
- return
- }
-
- q := r.URL.Query()
- rawPath := q.Get("p")
- if rawPath == "" {
- http.Error(w, "missing path", http.StatusBadRequest)
- return
- }
- if !ss.checkStreamToken(streamScopeThumb(rawPath), q.Get("t")) {
- clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
- log.Printf("[thumbnail] rejected from %s — bad/absent token", clientIP)
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- // Self-heal a host→container base-path skew AFTER the token bound the original
- // web path (mirrors /stream + /hls). On a docker agent the prewarm wrote its
- // sidecars next to the real file, so cache lookups must key on the healed path.
- rawPath = ss.healMediaPath(rawPath)
- if fi, err := os.Stat(rawPath); err != nil || !fi.Mode().IsRegular() {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- pos := parseThumbPos(q.Get("pos"))
- width := parseThumbWidth(q.Get("w"))
-
- // Cache hit: serve a fresh sidecar (written by the scan-time prewarm — which
- // pre-extracts the 10/30/50/70/90% panel frames — or a prior request),
- // skipping ffmpeg. Checked BEFORE the ffmpeg guard so a pre-warmed frame is
- // still serveable even if ffmpeg was removed after the cache was filled.
- if jpeg, ok := mediainfo.ReadCachedThumbnail(rawPath, pos, width); ok {
- ss.writeJPEG(w, jpeg)
- return
- }
-
- // Beyond here we must extract on demand, which needs ffmpeg.
- if ss.ffmpegPath == "" {
- http.Error(w, "thumbnails unavailable", http.StatusServiceUnavailable)
- return
- }
-
- // Cap the work: a single keyframe decode is fast, but a corrupt/huge file or
- // a seek past EOF could hang ffmpeg. 20s is generous for a keyframe seek.
- ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
- defer cancel()
-
- cmd := exec.CommandContext(ctx, ss.ffmpegPath, buildThumbnailArgs(rawPath, pos, width)...)
- var stderr strings.Builder
- cmd.Stderr = &stderr
- out, err := cmd.Output()
- if err != nil || len(out) == 0 {
- // Fast input-seek (-ss before -i) can fail on files whose seek index is
- // imprecise or mildly corrupt: the demuxer lands mid-EBML element
- // ("invalid as first byte of an EBML number") and no frame decodes.
- // Retry once with the slow but robust output-seek path before giving up
- // (2026-06-03: anime MKVs returned a broken image in the web scrubber).
- log.Printf("[thumbnail] input-seek failed (pos=%.1f w=%d path=%q): err=%v %s — retrying output-seek",
- pos, width, rawPath, err, strings.TrimSpace(stderr.String()))
- var stderr2 strings.Builder
- cmd2 := exec.CommandContext(ctx, ss.ffmpegPath, buildThumbnailArgsAccurate(rawPath, pos, width)...)
- cmd2.Stderr = &stderr2
- out, err = cmd2.Output()
- if err != nil || len(out) == 0 {
- log.Printf("[thumbnail] no frame after output-seek fallback (pos=%.1f w=%d path=%q): err=%v %s",
- pos, width, rawPath, err, strings.TrimSpace(stderr2.String()))
- http.Error(w, "thumbnail failed", http.StatusInternalServerError)
- return
- }
- }
- // Write-through so the next request (and trickplay re-hover) is a cache hit.
- if ss.cacheThumbnails {
- if werr := mediainfo.WriteCachedThumbnail(rawPath, pos, width, out); werr != nil {
- log.Printf("[thumbnail] cache write skipped (pos=%.1f w=%d path=%q): %v", pos, width, rawPath, werr)
- }
- }
- ss.writeJPEG(w, out)
-}
-
-// writeJPEG writes the standard single-frame response headers + body for both
-// the cache-hit and freshly-extracted paths of thumbnailHandler.
-func (ss *StreamServer) writeJPEG(w http.ResponseWriter, jpeg []byte) {
- w.Header().Set("Content-Type", "image/jpeg")
- // path+pos is stable content; let the browser cache so re-opening the panel
- // doesn't re-fetch. private — it's a frame of the user's own file.
- w.Header().Set("Cache-Control", "private, max-age=3600")
- w.Header().Set("Content-Length", strconv.Itoa(len(jpeg)))
- if _, err := w.Write(jpeg); err != nil {
- log.Printf("[thumbnail] write failed: %v", err)
- }
-}
-
-// trickplayHandler serves the pre-built trickplay montage sprite (kind=sprite →
-// JPEG) or its manifest (default → JSON) for a file. The sprite is generated by
-// the scan-time prewarm (library.trickplay) so playback does NO live extraction
-// (no contention with the active stream — the cause of broken seekbar previews).
-// The agent owns the tile width (its config), so the web requests by path only
-// and reads geometry from the manifest. Auth mirrors /thumbnail (a
-// thumb: token). 404 when no sprite exists yet → the web falls
-// back to on-demand /thumbnail.
-func (ss *StreamServer) trickplayHandler(w http.ResponseWriter, r *http.Request) {
- ss.lastActivity.Store(time.Now().UnixNano())
- if ss.writeCORSHeaders(w, r, "") {
- return
- }
- q := r.URL.Query()
- rawPath := q.Get("p")
- if rawPath == "" {
- http.Error(w, "missing path", http.StatusBadRequest)
- return
- }
- if !ss.checkStreamToken(streamScopeThumb(rawPath), q.Get("t")) {
- clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
- log.Printf("[trickplay] rejected from %s — bad/absent token", clientIP)
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if ss.trickplayWidth <= 0 {
- http.Error(w, "trickplay disabled", http.StatusNotFound)
- return
- }
- rawPath = ss.healMediaPath(rawPath) // host→container base-path skew (see /thumbnail)
- if fi, err := os.Stat(rawPath); err != nil || !fi.Mode().IsRegular() {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- manifest, ok := mediainfo.ReadCachedTrickplay(rawPath, ss.trickplayWidth)
- if !ok {
- http.Error(w, "trickplay not available", http.StatusNotFound)
- return
- }
- if q.Get("kind") == "sprite" {
- f, err := os.Open(mediainfo.TrickplaySpritePath(rawPath, ss.trickplayWidth))
- if err != nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- defer f.Close()
- mod := time.Time{}
- if fi, serr := f.Stat(); serr == nil {
- mod = fi.ModTime()
- }
- w.Header().Set("Content-Type", "image/jpeg")
- w.Header().Set("Cache-Control", "private, max-age=3600")
- http.ServeContent(w, r, "trickplay.jpg", mod, f)
- return
- }
- w.Header().Set("Content-Type", "application/json")
- w.Header().Set("Cache-Control", "private, max-age=3600")
- if err := json.NewEncoder(w).Encode(manifest); err != nil {
- log.Printf("[trickplay] manifest encode failed: %v", err)
- }
-}
-
-// subtitleHandler extracts ONE embedded TEXT subtitle stream from a file and
-// serves it as WebVTT, on demand. It's the single subtitle source the web
-// player uses for BOTH direct-play and HLS (attached as an external ),
-// so subtitles are identical regardless of play method or whether playback runs
-// natively or via hls.js — no longer dependent on the browser's HLS engine
-// surfacing in-manifest renditions.
-//
-// Mirrors thumbnailHandler: path in ?p= (client-visible), index in ?i=, and the
-// token scope binds path+index so a tampered p/i fails verification. 404 on a
-// bad token (no oracle). The path is clamped to a regular file as defense in
-// depth. Bitmap subs (PGS/DVB) have no text form — those are burned in via the
-// HLS path and are not served here; the web only requests text tracks.
-func (ss *StreamServer) subtitleHandler(w http.ResponseWriter, r *http.Request) {
- ss.lastActivity.Store(time.Now().UnixNano())
- if ss.writeCORSHeaders(w, r, "") {
- return
- }
-
- q := r.URL.Query()
- rawPath := q.Get("p")
- if rawPath == "" {
- http.Error(w, "missing path", http.StatusBadRequest)
- return
- }
- // index >= 0 → EMBEDDED stream index (-map 0:s:N) of the media at `p`.
- // index < 0 → EXTERNAL sidecar: `p` IS the subtitle file; the whole file is
- // the track. Both bind the token to (path, index) so a tampered p/i fails.
- index, err := strconv.Atoi(q.Get("i"))
- if err != nil {
- http.Error(w, "bad index", http.StatusBadRequest)
- return
- }
- if !ss.checkStreamToken(streamScopeSub(rawPath, index), q.Get("t")) {
- clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
- log.Printf("[sub] rejected from %s — bad/absent token", clientIP)
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
-
- external := index < 0
- // A debrid/HLS-from-URL source has no local file — ffmpeg reads the URL
- // directly. Skip the path heal + regular-file stat + on-disk cache for those;
- // only local files get the sidecar cache.
- isURL := strings.Contains(rawPath, "://")
- langHint := q.Get("l") // ISO 639-1 charset hint for external sidecar decoding
-
- if !isURL {
- rawPath = ss.healMediaPath(rawPath) // host→container base-path skew (see /thumbnail)
- if fi, statErr := os.Stat(rawPath); statErr != nil || !fi.Mode().IsRegular() {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- // Cache hit: serve a fresh sidecar (written by the scan-time prewarm or a
- // prior request) instantly, skipping ffmpeg. This is also what makes huge
- // remuxes work — the prewarm extracts without the on-demand HTTP timeout
- // below, so by play time the hit avoids the 60s ceiling that was returning
- // 500s on 50GB+ files. Checked BEFORE the ffmpeg guard so a pre-warmed track
- // is still serveable even if ffmpeg was removed after the cache was filled.
- if vtt, ok := mediainfo.ReadCachedSubtitle(rawPath, index); ok {
- ss.writeVTT(w, vtt)
- return
- }
- }
-
- // Beyond here we must extract on demand, which needs ffmpeg.
- if ss.ffmpegPath == "" {
- http.Error(w, "subtitles unavailable", http.StatusServiceUnavailable)
- return
- }
-
- // A full subtitle track is small (KBs–low MBs); 60s is ample for a normal
- // movie's text track and bounds a hung/corrupt ffmpeg. Giant remuxes can
- // exceed this on first play — the prewarm pre-fills the cache so this
- // on-demand path is the fallback, not the steady state.
- ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
- defer cancel()
-
- var out []byte
- if external {
- // Standalone sidecar file: transcode charset → UTF-8 (langHint guides the
- // code-page guess) then ffmpeg → WebVTT.
- out, err = mediainfo.ExtractExternalSubtitleVTT(ctx, ss.ffmpegPath, rawPath, langHint)
- } else {
- out, err = mediainfo.ExtractSubtitleVTT(ctx, ss.ffmpegPath, rawPath, index)
- }
- if err != nil {
- log.Printf("[sub] extract failed (i=%d path=%q external=%v url=%v): %v", index, rawPath, external, isURL, err)
- http.Error(w, "subtitle extract failed", http.StatusInternalServerError)
- return
- }
- // Write-through so the next request is a cache hit. Best-effort: a read-only
- // media mount just logs and serves the in-memory bytes. URL sources have no
- // stable on-disk anchor for the sidecar cache → skip.
- if ss.cacheSubtitles && !isURL {
- if werr := mediainfo.WriteCachedSubtitle(rawPath, index, out); werr != nil {
- log.Printf("[sub] cache write skipped (i=%d path=%q): %v", index, rawPath, werr)
- }
- }
- ss.writeVTT(w, out)
-}
-
-// attachSubtitleVTTURLs enriches a ProbeInfo map's "subtitles" entries with a
-// ready-to-use, tokened `vttUrl` for every TEXT track, so the web player can
-// attach s for ANY play method (torrent/debrid HLS included) without the
-// server needing the source path — it's the single subtitle wiring path that
-// makes embedded subs work on streams that were never library-scanned.
-//
-// - embedded (external=false): /sub?p=&i=&t=
-// - external (external=true) : /sub?p=&i=-1&t=&l=
-//
-// The token uses the SAME streamScopeSub(path,index) the web mints with, so a
-// library-scanned track and a probe-derived one address identically. The raw
-// "path" key is removed after the URL is built (it's encoded in the URL already).
-// URLs are root-relative; the player resolves them against the funnel origin it
-// fetched probe.json from. Bitmap tracks get no vttUrl (burn-in only).
-func (ss *StreamServer) attachSubtitleVTTURLs(info map[string]any, srcRef string) {
- subsAny, ok := info["subtitles"].([]map[string]any)
- if !ok {
- return
- }
- now := time.Now()
- for _, sb := range subsAny {
- isText, _ := sb["text"].(bool)
- if !isText {
- delete(sb, "path")
- continue
- }
- external, _ := sb["external"].(bool)
- var p string
- var idx int
- if external {
- p, _ = sb["path"].(string)
- idx = -1
- } else {
- p = srcRef
- if iv, ok := sb["index"].(int); ok {
- idx = iv
- }
- }
- if p == "" {
- delete(sb, "path")
- continue
- }
- tok := mintStreamToken(ss.streamSecret, streamScopeSub(p, idx), now)
- u := "/sub?p=" + url.QueryEscape(p) + "&i=" + strconv.Itoa(idx) + "&t=" + tok
- if external {
- if lang, _ := sb["lang"].(string); lang != "" && lang != "und" {
- u += "&l=" + url.QueryEscape(lang)
- }
- }
- sb["vttUrl"] = u
- delete(sb, "path")
- }
-}
-
-// writeVTT writes the standard WebVTT response headers + body for both the
-// cache-hit and freshly-extracted paths of subtitleHandler.
-func (ss *StreamServer) writeVTT(w http.ResponseWriter, vtt []byte) {
- w.Header().Set("Content-Type", "text/vtt; charset=utf-8")
- // path+index is stable content for the daemon's lifetime; let the browser
- // cache so re-selecting a track doesn't re-fetch. private — the user's file.
- w.Header().Set("Cache-Control", "private, max-age=3600")
- w.Header().Set("Content-Length", strconv.Itoa(len(vtt)))
- //nolint:gosec // G705: WebVTT served as text/vtt to a element — not
- // HTML, so cue text can't execute; the path is token-scoped + stat'd as a
- // regular file, and ffmpeg only emits well-formed WebVTT.
- if _, err := w.Write(vtt); err != nil {
- log.Printf("[sub] write failed: %v", err)
- }
-}
-
-// buildThumbnailArgs builds the ffmpeg argv that decodes ONE frame at posSec and
-// writes a scaled JPEG to stdout. `-ss` BEFORE `-i` does an input (keyframe)
-// seek — near-constant time regardless of position — instead of decoding from
-// the start. scale=w:-2 preserves aspect with an even height (mjpeg/yuv420
-// requires even dimensions). `-an -sn` drops audio/subtitle streams.
-func buildThumbnailArgs(path string, posSec float64, width int) []string {
- return []string{
- "-nostdin",
- "-loglevel", "error",
- "-ss", strconv.FormatFloat(posSec, 'f', 3, 64),
- "-i", path,
- "-frames:v", "1",
- "-vf", fmt.Sprintf("scale=%d:-2", width),
- "-an", "-sn",
- "-f", "mjpeg",
- "pipe:1",
- }
-}
-
-// buildThumbnailArgsAccurate is the robust fallback for files whose seek index
-// is imprecise or mildly corrupt, where the fast input seek (-ss before -i)
-// lands mid-EBML element and decodes no frame. `-ss` AFTER `-i` is an output
-// (decode) seek — slower (decodes from the start) but reliable — and
-// `-err_detect ignore_err` tolerates minor stream corruption encountered along
-// the way. Only used after buildThumbnailArgs fails, so its extra cost is paid
-// solely for files the fast path can't handle.
-func buildThumbnailArgsAccurate(path string, posSec float64, width int) []string {
- return []string{
- "-nostdin",
- "-loglevel", "error",
- "-err_detect", "ignore_err",
- "-i", path,
- "-ss", strconv.FormatFloat(posSec, 'f', 3, 64),
- "-frames:v", "1",
- "-vf", fmt.Sprintf("scale=%d:-2", width),
- "-an", "-sn",
- "-f", "mjpeg",
- "pipe:1",
- }
-}
-
-// parseThumbPos parses a non-negative seconds offset; defaults to 0 on garbage.
-func parseThumbPos(s string) float64 {
- if s == "" {
- return 0
- }
- v, err := strconv.ParseFloat(s, 64)
- if err != nil || v < 0 {
- return 0
- }
- return v
-}
-
-// parseThumbWidth parses the requested width, defaulting to 320 and clamping to
-// [80, 640] so a caller can't ask ffmpeg to upscale to an absurd size.
-func parseThumbWidth(s string) int {
- const def, min, max = 320, 80, 640
- if s == "" {
- return def
- }
- v, err := strconv.Atoi(s)
- if err != nil {
- return def
- }
- if v < min {
- return min
- }
- if v > max {
- return max
- }
- return v
-}
-
-// serveGrowing range-serves a growing remux source (hueco #3 / 3b). Unlike
-// http.ServeContent it can't rely on a fixed file size: ffmpeg `-c copy` is
-// still writing, and the final byte count isn't known until it exits. So we:
-//
-// - advertise an ESTIMATED total (≈ source file size for a copy remux) in
-// Content-Range so the browser scrubber has a timeline;
-// - reply 206 and stream from the requested offset, blocking via ReadAt for
-// not-yet-produced bytes, until the explicit range end or the real EOF;
-// - send the body chunked (no Content-Length) for non-final sources, since
-// the true length differs from the estimate — promising an exact length we
-// can't fulfil would hang the browser. When the source is already final we
-// send an exact Content-Length.
-//
-// Seeking forward into a not-yet-remuxed region blocks briefly until the copy
-// (I/O-bound, fast) catches up; seeking back to produced bytes is immediate.
-func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src GrowingSource) {
- w.Header().Set("Accept-Ranges", "bytes")
- w.Header().Set("Content-Type", "video/mp4")
- w.Header().Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", src.FileName()))
-
- // Total to advertise. iOS/WebKit opens every with a tiny
- // "bytes=0-1" probe and REFUSES to play unless that 206 carries a concrete
- // instance length in Content-Range (bytes 0-1/); "/*" (unknown total)
- // makes it bail and re-bootstrap the session forever. So we always advertise
- // a numeric total: the exact size once ffmpeg has exited, the estimate while
- // still growing. The estimate isn't the byte-exact final size (the audio
- // re-encode + fMP4 fragmentation shift it), but that's fine — the real
- // re-seek loop on a growing source was the malformed init segment, fixed by
- // the encoder's +delay_moov (see buildFFmpegArgs), NOT the advertised total.
- final := src.Final()
- total := src.EstimatedSize()
- if final {
- total = src.Size()
- }
- if total <= 0 {
- total = src.Size()
- }
-
- start, explicitEnd := parseByteRange(r.Header.Get("Range"))
- if total > 0 && start >= total {
- // Range beyond what we expect to produce — let the browser recover.
- w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", total))
- http.Error(w, "range not satisfiable", http.StatusRequestedRangeNotSatisfiable)
- return
- }
-
- if r.Method == http.MethodHead {
- if total > 0 {
- w.Header().Set("Content-Length", strconv.FormatInt(total, 10))
- }
- w.WriteHeader(http.StatusOK)
- return
- }
-
- end := total - 1
- if explicitEnd >= 0 && explicitEnd < end {
- end = explicitEnd
- }
- if end < start {
- end = start
- }
- if total > 0 {
- w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, total))
- }
- // Exact Content-Length only when the source is final (true size known) so we
- // never promise bytes a still-running remux might not produce.
- if final && explicitEnd < 0 {
- w.Header().Set("Content-Length", strconv.FormatInt(src.Size()-start, 10))
- }
- w.WriteHeader(http.StatusPartialContent)
-
- buf := make([]byte, 256*1024)
- off := start
- firstRead := true
- for {
- if explicitEnd >= 0 && off > explicitEnd {
- return
- }
- if r.Context().Err() != nil {
- return // client disconnected / request cancelled
- }
- readStart := time.Now()
- n, err := src.ReadAt(buf, off)
- // TTFF diagnosis: a read that blocks means the client asked for bytes the
- // remux hasn't produced yet (a seek ahead of the live edge, or the very
- // first read before ffmpeg's init lands). Log it so a slow start is
- // attributable to "waiting on ffmpeg" vs network/decoder.
- if waited := time.Since(readStart); waited > 250*time.Millisecond {
- log.Printf("[stream] serveGrowing read off=%d blocked %v (produced=%d est=%d)",
- off, waited.Round(time.Millisecond), src.Size(), src.EstimatedSize())
- } else if firstRead {
- log.Printf("[stream] serveGrowing start off=%d (produced=%d est=%d)", start, src.Size(), src.EstimatedSize())
- }
- firstRead = false
- if n > 0 {
- toWrite := n
- if explicitEnd >= 0 {
- if remaining := explicitEnd - off + 1; int64(toWrite) > remaining {
- toWrite = int(remaining)
- }
- }
- if _, werr := w.Write(buf[:toWrite]); werr != nil {
- return // client gone
- }
- off += int64(toWrite)
- if f, ok := w.(http.Flusher); ok {
- f.Flush()
- }
- }
- if err != nil {
- // transcodeSource returns io.EOF only at the true (final) end; any
- // other error means ffmpeg failed or the read timed out. Either
- // way the stream is over — close the body.
- return
- }
- }
-}
-
-// parseByteRange parses a single "bytes=start-[end]" header into (start, end).
-// end is -1 when open-ended or absent. Multi-range and suffix ranges
-// ("bytes=-N") are not supported (returns start=0) — the browser falls back to
-// a normal open-ended request, which is all needs for a growing source.
-func parseByteRange(header string) (start, end int64) {
- end = -1
- if !strings.HasPrefix(header, "bytes=") {
- return 0, -1
- }
- spec := strings.TrimPrefix(header, "bytes=")
- if i := strings.IndexByte(spec, ','); i >= 0 {
- spec = spec[:i] // first range only
- }
- dash := strings.IndexByte(spec, '-')
- if dash < 0 {
- return 0, -1
- }
- startStr := strings.TrimSpace(spec[:dash])
- if startStr == "" {
- // Suffix range "bytes=-N" (last N bytes) is unsupported on a growing
- // source whose total isn't fixed — serve open-ended from 0 instead of
- // mis-reading N as an absolute end. fMP4 (moov at front) never needs it.
- return 0, -1
- }
- if v, err := strconv.ParseInt(startStr, 10, 64); err == nil && v >= 0 {
- start = v
- }
- if e := strings.TrimSpace(spec[dash+1:]); e != "" {
- if v, err := strconv.ParseInt(e, 10, 64); err == nil && v >= 0 {
- end = v
- }
- }
- return start, end
-}
-
// EstimatedProgress returns estimated watch progress percentage (0-100)
// and the total duration in seconds (0 if unknown).
func (ss *StreamServer) EstimatedProgress() (pct int, durationSec int) {
@@ -1687,37 +689,19 @@ func (p *diskFileProvider) FileSize() int64 {
}
// NewTorrentFileProvider creates a FileProvider from an active torrent file.
-// dataDir locates the on-disk file for a best-effort bitrate probe that sizes
-// the streaming readahead. The probe runs ASYNC so stream start never blocks on
-// ffprobe (a missing header would otherwise stall up to the probe timeout);
-// until it resolves, readers use the default window, and readers created after
-// it resolves pick up the accurate size.
-func NewTorrentFileProvider(file *torrent.File, dataDir string) FileProvider {
- p := &torrentFileProvider{file: file}
- if dataDir != "" {
- go func() {
- if bps := probeMediaInfo(filepath.Join(dataDir, file.DisplayPath())).bitrateBps; bps > 0 {
- p.bitrateBps.Store(bps)
- }
- }()
- }
- return p
+func NewTorrentFileProvider(file *torrent.File) FileProvider {
+ return &torrentFileProvider{file: file}
}
// torrentFileProvider wraps a torrent.File to implement FileProvider.
type torrentFileProvider struct {
file *torrent.File
- // bitrateBps sizes the reader's readahead window (see dynamicReadahead).
- // Set asynchronously by the bitrate probe; 0 until then → default window.
- bitrateBps atomic.Int64
}
func (p *torrentFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
reader := p.file.NewReader()
reader.SetResponsive()
- // Bitrate-sized window (vs the old static 5 MiB that stalled HD/4K). anacrolix
- // prioritises pieces in this window ahead of the read position + on seek.
- reader.SetReadahead(dynamicReadahead(p.bitrateBps.Load()))
+ reader.SetReadahead(5 * 1024 * 1024)
reader.SetContext(ctx)
return reader
}
diff --git a/internal/engine/stream_server_test.go b/internal/engine/stream_server_test.go
index 1c49487..46f3e8c 100644
--- a/internal/engine/stream_server_test.go
+++ b/internal/engine/stream_server_test.go
@@ -35,33 +35,6 @@ func (f *fakeFileProviderSeekable) NewFileReader(_ context.Context) io.ReadSeekC
return &readSeekNopCloser{strings.NewReader(string(f.content))}
}
-// TestStreamServer_healMediaPath covers the host→container base-path self-heal
-// used by the path-scoped handlers (/thumbnail, /trickplay, /sub).
-func TestStreamServer_healMediaPath(t *testing.T) {
- srv := NewStreamServer(0)
-
- // No resolver installed → identity (preserves the pre-fix 404 behaviour).
- if got := srv.healMediaPath("/mnt/nas/peliculas/a/b/c.mkv"); got != "/mnt/nas/peliculas/a/b/c.mkv" {
- t.Errorf("nil resolver should be identity, got %q", got)
- }
-
- // Resolver locates the file under a current root → use the healed path.
- srv.SetPathResolver(func(p string) string {
- if p == "/mnt/nas/peliculas/a/b/c.mkv" {
- return "/downloads/a/b/c.mkv"
- }
- return ""
- })
- if got := srv.healMediaPath("/mnt/nas/peliculas/a/b/c.mkv"); got != "/downloads/a/b/c.mkv" {
- t.Errorf("resolver remap: got %q want /downloads/a/b/c.mkv", got)
- }
-
- // Resolver can't locate it ("") → keep the original so os.Stat 404s as before.
- if got := srv.healMediaPath("/elsewhere/x.mkv"); got != "/elsewhere/x.mkv" {
- t.Errorf("unlocatable path should stay unchanged, got %q", got)
- }
-}
-
// TestStreamServer_Listen_BindsPort verifica que Listen() enlaza a un puerto
// y URL() devuelve una URL accesible.
func TestStreamServer_Listen_BindsPort(t *testing.T) {
diff --git a/internal/engine/stream_server_tls_test.go b/internal/engine/stream_server_tls_test.go
deleted file mode 100644
index d572b93..0000000
--- a/internal/engine/stream_server_tls_test.go
+++ /dev/null
@@ -1,151 +0,0 @@
-package engine
-
-import (
- "context"
- "crypto/ecdsa"
- "crypto/elliptic"
- "crypto/rand"
- "crypto/tls"
- "crypto/x509"
- "crypto/x509/pkix"
- "fmt"
- "math/big"
- "net"
- "net/http"
- "testing"
- "time"
-)
-
-// genSelfSignedCert builds an in-memory self-signed cert valid for 127.0.0.1,
-// used to exercise the agent's HTTPS listener without any CA/ACME plumbing.
-func genSelfSignedCert(t *testing.T) (tls.Certificate, *x509.Certificate) {
- t.Helper()
- key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- if err != nil {
- t.Fatalf("genkey: %v", err)
- }
- tmpl := &x509.Certificate{
- SerialNumber: big.NewInt(1),
- Subject: pkix.Name{CommonName: "unarr-test"},
- NotBefore: time.Now().Add(-time.Hour),
- NotAfter: time.Now().Add(time.Hour),
- KeyUsage: x509.KeyUsageDigitalSignature,
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
- IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
- DNSNames: []string{"localhost"},
- }
- der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
- if err != nil {
- t.Fatalf("create cert: %v", err)
- }
- leaf, err := x509.ParseCertificate(der)
- if err != nil {
- t.Fatalf("parse cert: %v", err)
- }
- return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
-}
-
-// freePort grabs an ephemeral TCP port and releases it, so the caller can hand
-// a concrete port number to EnableTLS (which treats 0 as "disabled").
-func freePort(t *testing.T) int {
- t.Helper()
- l, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("freePort: %v", err)
- }
- p := l.Addr().(*net.TCPAddr).Port
- l.Close()
- return p
-}
-
-// TestStreamServerTLS_HotInstall verifies the HTTPS listener: it starts even
-// with no certificate (handshake fails), and a certificate installed *after*
-// Listen applies live via the GetCertificate path — no restart, which is what
-// the future ACME broker relies on.
-func TestStreamServerTLS_HotInstall(t *testing.T) {
- cert, leaf := genSelfSignedCert(t)
-
- ss := NewStreamServer(0) // HTTP on a random free port
- ss.EnableTLS(freePort(t))
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- if err := ss.Listen(ctx); err != nil {
- t.Fatalf("Listen: %v", err)
- }
- defer ss.Shutdown(context.Background())
-
- if ss.HTTPSPort() == 0 {
- t.Fatal("HTTPSPort() = 0, want the armed HTTPS port")
- }
- if ss.HasTLSCertificate() {
- t.Fatal("no certificate should be installed yet")
- }
-
- pool := x509.NewCertPool()
- pool.AddCert(leaf)
- client := &http.Client{
- Timeout: 3 * time.Second,
- Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}},
- }
- url := fmt.Sprintf("https://127.0.0.1:%d/health", ss.HTTPSPort())
-
- // Before a cert is installed, the handshake must fail.
- if resp, err := client.Get(url); err == nil {
- resp.Body.Close()
- t.Fatal("GET succeeded before a certificate was installed; want handshake failure")
- }
-
- // Install the cert — the listener stays up and the next handshake succeeds.
- ss.SetTLSCertificate(&cert)
- if !ss.HasTLSCertificate() {
- t.Fatal("HasTLSCertificate() = false after install")
- }
-
- var lastErr error
- for attempt := 0; attempt < 20; attempt++ {
- resp, err := client.Get(url)
- if err != nil {
- lastErr = err
- time.Sleep(50 * time.Millisecond)
- continue
- }
- resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- t.Fatalf("GET %s = %d, want 200", url, resp.StatusCode)
- }
- return // success
- }
- t.Fatalf("GET %s never succeeded after cert install: %v", url, lastErr)
-}
-
-// TestStreamServerTLS_Disabled verifies that with TLS not armed, no HTTPS port
-// is opened and the HTTP listener is unaffected.
-func TestStreamServerTLS_Disabled(t *testing.T) {
- ss := NewStreamServer(0)
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- if err := ss.Listen(ctx); err != nil {
- t.Fatalf("Listen: %v", err)
- }
- defer ss.Shutdown(context.Background())
-
- if ss.HTTPSPort() != 0 {
- t.Errorf("HTTPSPort() = %d, want 0 (TLS disabled)", ss.HTTPSPort())
- }
-}
-
-// TestLoadTLSCertificateFromFiles_Missing verifies the loader reports an error
-// (not a panic) when the cert pair is absent — the daemon treats this as
-// "TLS off, HTTP keeps serving".
-func TestLoadTLSCertificateFromFiles_Missing(t *testing.T) {
- ss := NewStreamServer(0)
- err := ss.LoadTLSCertificateFromFiles(
- t.TempDir()+"/nope.crt", t.TempDir()+"/nope.key")
- if err == nil {
- t.Fatal("expected error loading a missing cert pair")
- }
- if ss.HasTLSCertificate() {
- t.Error("no certificate should be installed after a failed load")
- }
-}
diff --git a/internal/engine/stream_source.go b/internal/engine/stream_source.go
index 58f66ba..b418e61 100644
--- a/internal/engine/stream_source.go
+++ b/internal/engine/stream_source.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"io"
- "log"
"os"
"path/filepath"
"strings"
@@ -126,20 +125,7 @@ func newTranscodeSource(
return nil, err
}
- // Size estimate for the scrubber timeline. A copy remux (video not
- // re-encoded) lands within container overhead of the source file, so the
- // source size is a far better estimate than bitrate×duration — use it.
- // A real transcode re-encodes, so fall back to the bitrate×duration model.
- var estimate int64
- switch action {
- case ActionPassthrough, ActionRemux, ActionRemuxAudio:
- if fi, statErr := os.Stat(srcPath); statErr == nil {
- estimate = fi.Size()
- }
- }
- if estimate <= 0 {
- estimate = estimateOutputSize(probe, opts)
- }
+ estimate := estimateOutputSize(probe, opts)
t := &transcodeSource{
tmpPath: tmpPath,
@@ -165,31 +151,6 @@ func newTranscodeSource(
return t, nil
}
-// NewRemuxSource starts an ffmpeg `-c copy` remux of srcPath into a growing
-// fragmented-MP4 temp file and returns it as a GrowingSource for /stream
-// (hueco #3 / 3b). The video + audio are copied (never re-encoded), so this is
-// only valid when the codecs are already browser-native (h264 + aac) and only
-// the container needs changing — the web's decidePlayMethod enforces that
-// before sending PlayMethod="remux". The browser plays the result progressively
-// via byte-range. Caller MUST Close() it (kills ffmpeg + removes the temp file).
-func NewRemuxSource(ctx context.Context, srcPath string, probe *StreamProbe, ffmpegPath, displayName string) (GrowingSource, error) {
- // Audio: copy when already AAC; otherwise transcode to AAC (ActionRemuxAudio).
- // Either way the VIDEO is copied — the expensive part is never re-encoded.
- // This lets remux cover the very common h264+AC3/DTS mkv case (hueco #3 / 3c),
- // not just h264+AAC.
- action := ActionRemux
- if probe != nil && probe.AudioCodec != "" && probe.AudioCodec != "aac" {
- action = ActionRemuxAudio
- }
- opts := TranscodeOpts{Action: action, FFmpegPath: ffmpegPath}
- // HEVC muxed into MP4 must carry the hvc1 tag or Apple/Safari won't decode
- // it (hueco #3 / 3c). h264 (avc1) needs no override.
- if probe != nil && probe.VideoCodec == "hevc" {
- opts.VideoTag = "hvc1"
- }
- return newTranscodeSource(ctx, srcPath, probe, action, opts, displayName)
-}
-
// signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a
// notification is already pending the new event is folded into it (callers
// always re-check size + final after waking, so a coalesced signal still
@@ -223,13 +184,6 @@ func (t *transcodeSource) watchSize(ctx context.Context) {
}
current := stat.Size()
if current > t.size.Load() {
- if t.size.Load() == 0 && current > 0 {
- // TTFF diagnosis: how long from ffmpeg spawn to the first
- // fMP4 bytes (init + first fragment) landing — the floor on
- // when /stream can serve anything playable.
- log.Printf("[stream] %s first fMP4 bytes after %v (%d KB)",
- t.name, time.Since(t.startedAt).Round(time.Millisecond), current/1024)
- }
t.size.Store(current)
t.signalNotify()
}
diff --git a/internal/engine/stream_source_debrid.go b/internal/engine/stream_source_debrid.go
deleted file mode 100644
index 79c34e7..0000000
--- a/internal/engine/stream_source_debrid.go
+++ /dev/null
@@ -1,340 +0,0 @@
-// Package engine — stream_source_debrid.go implements a FileProvider that
-// serves a /stream session straight from a debrid HTTPS direct URL (hueco #2 /
-// 2a). No local file is involved: the browser's Range requests are translated
-// into ranged GETs against the debrid link, so a cache-confirmed torrent plays
-// instantly without ever hitting the swarm or touching disk.
-//
-// The web resolves the DirectURL server-side (resolveDebridDirectUrl) and only
-// sends it when the hash is debrid-cached and the container is browser-native
-// (mp4/m4v), so this provider stays a pure pass-through — same role as
-// diskFileProvider/torrentFileProvider, just backed by HTTP Range instead of a
-// file handle. http.ServeContent drives it exactly like a local file: it Seeks
-// to discover size + the range start (no network), then Reads (lazy GET).
-package engine
-
-import (
- "context"
- "errors"
- "fmt"
- "io"
- "log"
- "net/http"
- "path"
- "strings"
- "sync"
- "time"
-)
-
-// debridHTTPClient is used for ranged debrid reads. Separate from the download
-// httpClient so a slow streaming read can't starve a concurrent download's
-// header-timeout budget, and vice versa. No overall timeout: a paused player
-// can legitimately hold a body open for minutes; ResponseHeaderTimeout bounds
-// the part that actually matters (a hung server before first byte).
-var debridHTTPClient = &http.Client{
- Transport: &http.Transport{
- ResponseHeaderTimeout: 30 * time.Second,
- // debrid CDNs are remote; a generous idle-conn pool avoids a fresh TLS
- // handshake on every seek-driven reopen.
- MaxIdleConns: 4,
- IdleConnTimeout: 90 * time.Second,
- TLSHandshakeTimeout: 15 * time.Second,
- },
-}
-
-// NewDebridFileProvider builds a FileProvider backed by a debrid HTTPS URL.
-// It performs a single HEAD up front to learn the exact file size (the torrent
-// size the web knows can differ from the resolved file's size). If the HEAD
-// fails or omits Content-Length, fallbackSize (from the StreamSession) is used.
-// Returns an error only when neither a HEAD size nor a fallback is available —
-// http.ServeContent needs a real size to range-serve, and serving size 0 would
-// hand the browser an empty file.
-// refresh, when non-nil, re-resolves a fresh debrid URL for the same content
-// (hueco #2 / 2c) — called when the current link expires mid-stream. nil keeps
-// 2a behaviour (an expired link is a hard error, no recovery).
-func NewDebridFileProvider(ctx context.Context, directURL, fileName string, fallbackSize int64, refresh func(context.Context) (string, error)) (FileProvider, error) {
- if directURL == "" {
- return nil, errors.New("debrid provider: empty direct URL")
- }
- size := fallbackSize
- if headSize, ok := debridHeadSize(ctx, directURL); ok {
- size = headSize
- }
- if size <= 0 {
- return nil, fmt.Errorf("debrid provider: unknown file size (HEAD gave nothing, no fallback)")
- }
- // The name drives the served Content-Type (mimeTypeFromExt on FileName).
- // The web may pass a torrent title with no extension (its file-name
- // fallback), which would yield application/octet-stream and break
- // on strict clients (Safari). The debrid URL reliably ends in the real
- // file name *with* its extension, so derive from it whenever the passed
- // name lacks one.
- name := fileName
- if name == "" || path.Ext(name) == "" {
- name = debridNameFromURL(directURL)
- }
- return &debridFileProvider{
- url: directURL,
- name: name,
- size: size,
- refresh: refresh,
- }, nil
-}
-
-// debridFileProvider serves a file from a debrid HTTPS URL via ranged GETs. The
-// URL is mutable: when it expires mid-stream, refreshURL swaps in a fresh one
-// (shared across all readers this provider hands out) so the next range request
-// uses the live link.
-type debridFileProvider struct {
- mu sync.Mutex
- url string
- lastRefreshAt time.Time
- inflight *refreshCall // non-nil while a refresh is running; coalesces concurrent callers
- refresh func(context.Context) (string, error)
-
- name string
- size int64
-}
-
-// refreshCall is a single in-flight refresh whose result is shared by every
-// reader that piles up behind it (singleflight). done is closed on completion.
-type refreshCall struct {
- done chan struct{}
- url string
- err error
-}
-
-// currentURL returns the live debrid URL (mutated by refreshURL on expiry).
-func (p *debridFileProvider) currentURL() string {
- p.mu.Lock()
- defer p.mu.Unlock()
- return p.url
-}
-
-// refreshURL re-resolves a fresh debrid link and stores it. A browser's
-// opens several concurrent range connections, so when a link expires N readers
-// hit it at once — they must NOT each fire a (multi-second) re-resolution.
-// Coalescing is two-layer: (1) a result refreshed in the last few seconds is
-// reused without any call; (2) while a refresh is in flight, late callers wait
-// on it and share its result (singleflight) rather than starting their own.
-func (p *debridFileProvider) refreshURL(ctx context.Context) (string, error) {
- if p.refresh == nil {
- return "", errors.New("debrid provider: no URL refresher (refresh disabled)")
- }
- p.mu.Lock()
- if time.Since(p.lastRefreshAt) < 5*time.Second && p.url != "" {
- u := p.url
- p.mu.Unlock()
- return u, nil // refreshed very recently — reuse it
- }
- if call := p.inflight; call != nil {
- p.mu.Unlock()
- select {
- case <-call.done:
- return call.url, call.err // shared result from the in-flight refresh
- case <-ctx.Done():
- return "", ctx.Err()
- }
- }
- call := &refreshCall{done: make(chan struct{})}
- p.inflight = call
- p.mu.Unlock()
-
- u, err := p.refresh(ctx)
-
- p.mu.Lock()
- if err == nil {
- p.url = u
- p.lastRefreshAt = time.Now()
- }
- call.url, call.err = u, err
- p.inflight = nil
- close(call.done)
- p.mu.Unlock()
-
- if err != nil {
- return "", err
- }
- log.Printf("[stream] debrid URL refreshed (expired mid-stream)")
- return u, nil
-}
-
-func (p *debridFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
- return &debridRangeReader{
- ctx: ctx,
- prov: p,
- size: p.size,
- }
-}
-
-func (p *debridFileProvider) FileName() string { return p.name }
-func (p *debridFileProvider) FileSize() int64 { return p.size }
-
-// debridRangeReader is an io.ReadSeekCloser over an HTTP resource that supports
-// Range. Seek is network-free: it only moves the logical position. Read opens
-// (or reuses) a GET starting at the current position and streams the body; a
-// Seek that moves away from the open body's cursor forces a reopen on the next
-// Read. This matches how http.ServeContent works — Seek(0, SeekEnd) for size,
-// Seek to the range start, then sequential Reads — so seeks the user makes in
-// the player become a single reopened GET, never a full re-download.
-type debridRangeReader struct {
- ctx context.Context
- prov *debridFileProvider
- size int64
-
- pos int64 // logical position (moved by Seek, advanced by Read)
- body io.ReadCloser // current open response body, or nil
- bodyAt int64 // position the open body's next byte maps to
-}
-
-func (r *debridRangeReader) Read(p []byte) (int, error) {
- if r.size > 0 && r.pos >= r.size {
- return 0, io.EOF
- }
- // (Re)open when no body is held or a Seek moved us off the open body.
- if r.body == nil || r.pos != r.bodyAt {
- if err := r.reopen(); err != nil {
- return 0, err
- }
- }
- n, err := r.body.Read(p)
- r.pos += int64(n)
- r.bodyAt = r.pos
- if err == io.EOF {
- // Body drained. Drop it so the next Read reopens (covers a server that
- // closed the connection before the logical EOF). Surface EOF to the
- // caller only when we've actually reached end-of-file; otherwise hand
- // back the bytes read with no error and let the caller Read again.
- _ = r.body.Close()
- r.body = nil
- if r.size > 0 && r.pos < r.size {
- return n, nil
- }
- }
- return n, err
-}
-
-func (r *debridRangeReader) Seek(offset int64, whence int) (int64, error) {
- var abs int64
- switch whence {
- case io.SeekStart:
- abs = offset
- case io.SeekCurrent:
- abs = r.pos + offset
- case io.SeekEnd:
- abs = r.size + offset
- default:
- return 0, fmt.Errorf("debrid reader: invalid whence %d", whence)
- }
- if abs < 0 {
- return 0, errors.New("debrid reader: negative position")
- }
- r.pos = abs
- return abs, nil
-}
-
-func (r *debridRangeReader) Close() error {
- if r.body != nil {
- err := r.body.Close()
- r.body = nil
- return err
- }
- return nil
-}
-
-// reopen issues a fresh ranged GET from the current logical position. Closes
-// any previously held body first. On an expired-link status (401/403/404/410)
-// it re-resolves a fresh debrid URL via the provider and retries — bounded, so
-// a permanently-dead link surfaces an error instead of looping (hueco #2 / 2c).
-func (r *debridRangeReader) reopen() error {
- if r.body != nil {
- _ = r.body.Close()
- r.body = nil
- }
- // Attempts: 1 initial + 1 after a URL refresh. One fresh link is enough for
- // an expiry; if the refreshed link ALSO fails the content is genuinely gone,
- // so surface the error rather than burning more multi-second resolutions.
- const maxAttempts = 2
- for attempt := 0; attempt < maxAttempts; attempt++ {
- req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.prov.currentURL(), nil)
- if err != nil {
- return fmt.Errorf("debrid reader: build request: %w", err)
- }
- // Always send a Range so a seek to 0 still gets a 206 (and so partial
- // reopens after a mid-file seek work). An open-ended range runs to EOF.
- req.Header.Set("Range", fmt.Sprintf("bytes=%d-", r.pos))
- resp, err := debridHTTPClient.Do(req)
- if err != nil {
- return fmt.Errorf("debrid reader: GET: %w", err)
- }
- switch resp.StatusCode {
- case http.StatusPartialContent:
- r.body = resp.Body
- r.bodyAt = r.pos
- return nil
- case http.StatusOK:
- // Server ignored Range and is sending the whole file from 0. Only
- // valid when we asked from 0; otherwise bytes wouldn't line up.
- if r.pos != 0 {
- resp.Body.Close()
- return fmt.Errorf("debrid reader: server ignored Range at offset %d (got 200)", r.pos)
- }
- r.body = resp.Body
- r.bodyAt = r.pos
- return nil
- case http.StatusRequestedRangeNotSatisfiable:
- resp.Body.Close()
- return io.EOF // seeked past end — treat as EOF, not a hard error
- case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusGone:
- // Expired/dead debrid link — re-resolve and retry with the fresh URL.
- resp.Body.Close()
- if _, rerr := r.prov.refreshURL(r.ctx); rerr != nil {
- return fmt.Errorf("debrid reader: link expired (%d) and refresh failed: %w", resp.StatusCode, rerr)
- }
- continue
- default:
- resp.Body.Close()
- return fmt.Errorf("debrid reader: unexpected status %d %s", resp.StatusCode, resp.Status)
- }
- }
- return fmt.Errorf("debrid reader: link still failing after %d refresh attempts", maxAttempts)
-}
-
-// debridHeadSize issues a HEAD and returns the Content-Length when present.
-// Best-effort: any failure returns (0, false) so the caller falls back to the
-// size the web reported. A short timeout keeps a slow/HEAD-hostile CDN from
-// stalling session setup — the fallback size is good enough to start.
-func debridHeadSize(ctx context.Context, url string) (int64, bool) {
- // 15s (not 10s): the transport's TLS handshake budget alone is 15s, so a
- // slow debrid CDN could trip the old 10s timeout before headers arrived,
- // needlessly falling back to a guessed size.
- hctx, cancel := context.WithTimeout(ctx, 15*time.Second)
- defer cancel()
- req, err := http.NewRequestWithContext(hctx, http.MethodHead, url, nil)
- if err != nil {
- return 0, false
- }
- resp, err := debridHTTPClient.Do(req)
- if err != nil {
- log.Printf("[stream] debrid HEAD failed (using fallback size): %v", err)
- return 0, false
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK || resp.ContentLength <= 0 {
- return 0, false
- }
- return resp.ContentLength, true
-}
-
-// debridNameFromURL extracts a filename from a URL path as a last resort when
-// the server didn't send one. Strips query/fragment via path.Base on the path.
-func debridNameFromURL(rawURL string) string {
- u := rawURL
- if i := strings.IndexAny(u, "?#"); i >= 0 {
- u = u[:i]
- }
- base := path.Base(u)
- if base == "" || base == "." || base == "/" {
- return "video.mp4"
- }
- return base
-}
diff --git a/internal/engine/stream_source_debrid_test.go b/internal/engine/stream_source_debrid_test.go
deleted file mode 100644
index 5af8d5d..0000000
--- a/internal/engine/stream_source_debrid_test.go
+++ /dev/null
@@ -1,368 +0,0 @@
-package engine
-
-import (
- "bytes"
- "context"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "sync"
- "sync/atomic"
- "testing"
- "time"
-)
-
-// rangeServer serves a fixed byte slice with full HTTP Range support via
-// http.ServeContent (the same machinery a real debrid CDN exposes). Records
-// the number of GETs so a test can assert that a seek triggers exactly one
-// reopen rather than a full re-download.
-func rangeServer(data []byte) (*httptest.Server, *int) {
- gets := 0
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodGet {
- gets++
- }
- http.ServeContent(w, r, "movie.mp4", time.Time{}, bytes.NewReader(data))
- }))
- return srv, &gets
-}
-
-func makeData(n int) []byte {
- b := make([]byte, n)
- for i := range b {
- b[i] = byte(i % 251) // non-trivial, deterministic pattern
- }
- return b
-}
-
-func TestDebridProviderHeadSize(t *testing.T) {
- data := makeData(4096)
- srv, _ := rangeServer(data)
- defer srv.Close()
-
- // HEAD reports the real size; fallback is ignored when HEAD succeeds.
- p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 999, nil)
- if err != nil {
- t.Fatalf("NewDebridFileProvider: %v", err)
- }
- if got := p.FileSize(); got != int64(len(data)) {
- t.Fatalf("FileSize from HEAD = %d, want %d", got, len(data))
- }
- if p.FileName() != "movie.mp4" {
- t.Fatalf("FileName = %q, want movie.mp4", p.FileName())
- }
-}
-
-func TestDebridProviderNameFromURLWhenNoExtension(t *testing.T) {
- // The web may pass a torrent title with no extension (its file-name
- // fallback). The provider must derive the name from the URL so the served
- // Content-Type is video/mp4, not application/octet-stream.
- data := makeData(1024)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- http.ServeContent(w, r, "x", time.Time{}, bytes.NewReader(data))
- }))
- defer srv.Close()
- p, err := NewDebridFileProvider(context.Background(), srv.URL+"/Movie.2026.1080p.mp4?token=abc", "Project Hail Mary (2026) 1080p web", 0, nil)
- if err != nil {
- t.Fatalf("provider: %v", err)
- }
- if got := p.FileName(); got != "Movie.2026.1080p.mp4" {
- t.Fatalf("FileName = %q, want Movie.2026.1080p.mp4 (derived from URL)", got)
- }
- // A passed name WITH an extension is kept as-is.
- p2, _ := NewDebridFileProvider(context.Background(), srv.URL+"/whatever.mp4", "Nice Title.mp4", 0, nil)
- if got := p2.FileName(); got != "Nice Title.mp4" {
- t.Fatalf("FileName = %q, want Nice Title.mp4 (kept)", got)
- }
-}
-
-func TestDebridProviderFallbackSizeWhenNoHead(t *testing.T) {
- // Server that refuses HEAD (405) but serves GET — provider must fall back
- // to the size the web reported.
- data := makeData(2048)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if r.Method == http.MethodHead {
- w.WriteHeader(http.StatusMethodNotAllowed)
- return
- }
- http.ServeContent(w, r, "movie.mp4", time.Time{}, bytes.NewReader(data))
- }))
- defer srv.Close()
-
- p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)), nil)
- if err != nil {
- t.Fatalf("NewDebridFileProvider: %v", err)
- }
- if got := p.FileSize(); got != int64(len(data)) {
- t.Fatalf("FileSize fallback = %d, want %d", got, len(data))
- }
-}
-
-func TestDebridProviderNoSizeFails(t *testing.T) {
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusMethodNotAllowed) // no HEAD, no usable GET
- }))
- defer srv.Close()
- // No HEAD size and fallback 0 → must error (ServeContent can't range-serve
- // size 0 without handing the browser an empty file).
- if _, err := NewDebridFileProvider(context.Background(), srv.URL, "", 0, nil); err == nil {
- t.Fatal("expected error when size is unknown, got nil")
- }
- if _, err := NewDebridFileProvider(context.Background(), "", "movie.mp4", 100, nil); err == nil {
- t.Fatal("expected error for empty URL, got nil")
- }
-}
-
-func TestDebridReaderSequentialRead(t *testing.T) {
- data := makeData(100_000)
- srv, gets := rangeServer(data)
- defer srv.Close()
-
- p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
- if err != nil {
- t.Fatalf("provider: %v", err)
- }
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
-
- got, err := io.ReadAll(rd)
- if err != nil {
- t.Fatalf("ReadAll: %v", err)
- }
- if !bytes.Equal(got, data) {
- t.Fatalf("sequential read mismatch: got %d bytes, want %d", len(got), len(data))
- }
- // A pure sequential read holds a single body to EOF → exactly one GET.
- if *gets != 1 {
- t.Fatalf("sequential read issued %d GETs, want 1", *gets)
- }
-}
-
-func TestDebridReaderSeekEndReportsSize(t *testing.T) {
- data := makeData(5000)
- srv, _ := rangeServer(data)
- defer srv.Close()
- p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
-
- // http.ServeContent calls Seek(0, SeekEnd) to learn the size — must be
- // network-free and return the total.
- size, err := rd.Seek(0, io.SeekEnd)
- if err != nil {
- t.Fatalf("Seek end: %v", err)
- }
- if size != int64(len(data)) {
- t.Fatalf("SeekEnd = %d, want %d", size, len(data))
- }
-}
-
-func TestDebridReaderSeekThenRead(t *testing.T) {
- data := makeData(50_000)
- srv, gets := rangeServer(data)
- defer srv.Close()
- p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
-
- const off = 12_345
- if _, err := rd.Seek(off, io.SeekStart); err != nil {
- t.Fatalf("seek: %v", err)
- }
- got, err := io.ReadAll(rd)
- if err != nil {
- t.Fatalf("ReadAll after seek: %v", err)
- }
- if !bytes.Equal(got, data[off:]) {
- t.Fatalf("tail mismatch: got %d bytes, want %d", len(got), len(data)-off)
- }
- // Seek is network-free; the read after it is the only GET.
- if *gets != 1 {
- t.Fatalf("seek+read issued %d GETs, want 1", *gets)
- }
-}
-
-func TestDebridReaderServeContentRoundTrip(t *testing.T) {
- // Drive the reader exactly like StreamServer does: hand it to
- // http.ServeContent and issue a ranged request. Verifies the reader is a
- // correct io.ReadSeeker for the production serving path.
- data := makeData(80_000)
- srv, _ := rangeServer(data)
- defer srv.Close()
- p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
-
- front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- rd := p.NewFileReader(r.Context())
- defer rd.Close()
- http.ServeContent(w, r, p.FileName(), time.Time{}, rd)
- }))
- defer front.Close()
-
- req, _ := http.NewRequest(http.MethodGet, front.URL, nil)
- req.Header.Set("Range", "bytes=10000-19999")
- resp, err := http.DefaultClient.Do(req)
- if err != nil {
- t.Fatalf("ranged GET: %v", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusPartialContent {
- t.Fatalf("status = %d, want 206", resp.StatusCode)
- }
- body, _ := io.ReadAll(resp.Body)
- want := data[10000:20000]
- if !bytes.Equal(body, want) {
- t.Fatalf("ranged body mismatch: got %d bytes", len(body))
- }
-}
-
-func TestDebridReaderSeekPastEnd(t *testing.T) {
- data := makeData(1000)
- srv, _ := rangeServer(data)
- defer srv.Close()
- p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
-
- // Seeking at/past size then reading yields EOF, no error, no bytes.
- if _, err := rd.Seek(int64(len(data)), io.SeekStart); err != nil {
- t.Fatalf("seek: %v", err)
- }
- n, err := rd.Read(make([]byte, 16))
- if n != 0 || err != io.EOF {
- t.Fatalf("read past end = (%d, %v), want (0, EOF)", n, err)
- }
-}
-
-// hueco #2 / 2c — an expired link (401) is recovered by re-resolving a fresh
-// URL via the refresh callback and retrying, transparent to the reader.
-func TestDebridReaderRefreshOnExpiry(t *testing.T) {
- data := makeData(20_000)
- live, _ := rangeServer(data)
- defer live.Close()
- expired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnauthorized) // link expired
- }))
- defer expired.Close()
-
- refreshed := 0
- refresh := func(_ context.Context) (string, error) {
- refreshed++
- return live.URL, nil
- }
- // HEAD on the expired URL 401s → falls back to the provided size.
- p, err := NewDebridFileProvider(context.Background(), expired.URL, "movie.mp4", int64(len(data)), refresh)
- if err != nil {
- t.Fatalf("provider: %v", err)
- }
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
-
- got, err := io.ReadAll(rd)
- if err != nil {
- t.Fatalf("ReadAll after expiry+refresh: %v", err)
- }
- if !bytes.Equal(got, data) {
- t.Fatalf("post-refresh read mismatch: got %d bytes, want %d", len(got), len(data))
- }
- if refreshed == 0 {
- t.Fatal("expected the reader to refresh the expired URL")
- }
-}
-
-// Coalescing: when N readers hit an expired link at once, only ONE refresh
-// runs (singleflight) and they all share its result — not N re-resolutions
-// hammering the web (hueco #2 / 2c).
-func TestDebridProviderRefreshCoalesces(t *testing.T) {
- data := makeData(8000)
- live, _ := rangeServer(data)
- defer live.Close()
-
- var refreshCalls int64
- refresh := func(_ context.Context) (string, error) {
- atomic.AddInt64(&refreshCalls, 1)
- time.Sleep(80 * time.Millisecond) // simulate a slow re-resolution
- return live.URL, nil
- }
- p := &debridFileProvider{url: "https://expired.invalid/x.mp4", refresh: refresh}
-
- const N = 8
- var wg sync.WaitGroup
- errs := make([]error, N)
- for i := 0; i < N; i++ {
- wg.Add(1)
- go func(i int) {
- defer wg.Done()
- _, errs[i] = p.refreshURL(context.Background())
- }(i)
- }
- wg.Wait()
-
- for i, err := range errs {
- if err != nil {
- t.Fatalf("reader %d refresh failed: %v", i, err)
- }
- }
- if got := atomic.LoadInt64(&refreshCalls); got != 1 {
- t.Fatalf("expected 1 coalesced refresh for %d concurrent readers, got %d", N, got)
- }
- if p.currentURL() != live.URL {
- t.Fatalf("provider URL = %q, want the refreshed live URL", p.currentURL())
- }
-}
-
-func TestDebridReaderRefreshFailsSurfacesError(t *testing.T) {
- expired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusForbidden)
- }))
- defer expired.Close()
- refresh := func(_ context.Context) (string, error) {
- return "", errors.New("debrid gone")
- }
- p, _ := NewDebridFileProvider(context.Background(), expired.URL, "movie.mp4", 1000, refresh)
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
- if _, err := rd.Read(make([]byte, 16)); err == nil {
- t.Fatal("expected an error when refresh fails, got nil")
- }
-}
-
-func TestDebridReaderNoRefresherExpiryIsHardError(t *testing.T) {
- // refresh == nil (2a behaviour): an expired link is a hard error, no retry.
- expired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusGone)
- }))
- defer expired.Close()
- p, _ := NewDebridFileProvider(context.Background(), expired.URL, "movie.mp4", 1000, nil)
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
- if _, err := rd.Read(make([]byte, 16)); err == nil {
- t.Fatal("expected a hard error with no refresher, got nil")
- }
-}
-
-func TestDebridReaderRejectsServerIgnoringRange(t *testing.T) {
- // A server that always returns 200 (ignores Range) is only safe at pos 0.
- // A reopen at a non-zero offset (after a seek) must error rather than serve
- // misaligned bytes.
- data := makeData(4000)
- srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.Header().Set("Content-Length", "4000")
- w.WriteHeader(http.StatusOK)
- w.Write(data)
- }))
- defer srv.Close()
- p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)), nil)
- if err != nil {
- t.Fatalf("provider: %v", err)
- }
- rd := p.NewFileReader(context.Background())
- defer rd.Close()
-
- if _, err := rd.Seek(1000, io.SeekStart); err != nil {
- t.Fatalf("seek: %v", err)
- }
- if _, err := rd.Read(make([]byte, 16)); err == nil {
- t.Fatal("expected error when server ignores Range at non-zero offset, got nil")
- }
-}
diff --git a/internal/engine/stream_token.go b/internal/engine/stream_token.go
deleted file mode 100644
index 7f7caf0..0000000
--- a/internal/engine/stream_token.go
+++ /dev/null
@@ -1,116 +0,0 @@
-package engine
-
-import (
- "crypto/hmac"
- "crypto/rand"
- "crypto/sha256"
- "crypto/subtle"
- "encoding/hex"
- "strconv"
- "strings"
- "time"
-)
-
-// Stream authentication.
-//
-// /stream and /hls have no header-based auth: a cannot attach an
-// Authorization header, and media-tag/segment requests are issued by the
-// browser, not our JS. So we bind a short-lived, unforgeable token to each
-// stream URL the daemon hands out and verify it on every request.
-//
-// The token is HMAC-signed by the daemon's own in-memory secret — there is no
-// server-side token store and no DB column. The web is a pure pass-through: it
-// stores and serves whatever tokenised URL the agent reports.
-//
-// - /stream (+ VLC playlist): token rides as a `?t=` query parameter.
-// - /hls: token rides as a PATH segment — /hls///
-// — so the relative child URIs inside the playlists (video/index.m3u8,
-// seg-N.m4s, subs/…) resolve under the same prefix and carry the token
-// automatically, with zero playlist rewriting.
-//
-// There is NO loopback exemption: the Cloudflare funnel proxies public traffic
-// to the daemon over localhost (cloudflared --url http://localhost:), so
-// a loopback source address is NOT a trust signal — exempting it would leave the
-// funnel (the headline public path) wide open. Every URL the agent/web hands a
-// player is already tokenised (URL(), URLsJSON, buildHlsUrls), so enforcing the
-// token unconditionally breaks no legitimate client. /health stays ungated (a
-// reachability probe that leaks nothing sensitive).
-
-const (
- // streamTokenTTL is how long a minted token stays valid. Long enough for a
- // movie plus pauses; short enough that a leaked URL stops working same-day.
- streamTokenTTL = 6 * time.Hour
-
- // streamScopeStream is the token scope for the single-file /stream endpoint.
- streamScopeStream = "stream"
-)
-
-// streamScopeHLS is the token scope for an HLS session. Binding to the session
-// id means a token minted for one session never validates another.
-func streamScopeHLS(sessionID string) string { return "hls:" + sessionID }
-
-// streamScopeThumb is the token scope for a single-frame thumbnail of a
-// specific file (the web's "file characteristics" panel). Binding the file
-// path's SHA-256 into the scope means a token minted for one file never
-// validates a thumbnail request for another — a leaked thumbnail URL exposes
-// only the one frame-source it was signed for. The web mints the matching
-// scope in src/lib/stream-token.ts (streamScopeThumb), byte-for-byte.
-func streamScopeThumb(filePath string) string {
- sum := sha256.Sum256([]byte(filePath))
- return "thumb:" + hex.EncodeToString(sum[:])
-}
-
-// streamScopeSub is the token scope for on-demand WebVTT extraction of one text
-// subtitle stream from a specific file (the /sub endpoint, used identically by
-// direct-play and HLS so subtitles are consistent across both). Binds the file
-// path's SHA-256 + the subtitle stream index, so a leaked URL exposes only that
-// one track of that one file. The web mints the matching scope in
-// src/lib/stream-token.ts (streamScopeSub), byte-for-byte.
-func streamScopeSub(filePath string, index int) string {
- sum := sha256.Sum256([]byte(filePath))
- return "sub:" + hex.EncodeToString(sum[:]) + ":" + strconv.Itoa(index)
-}
-
-// newStreamSecret returns 32 cryptographically-random bytes used to sign stream
-// tokens for the lifetime of the daemon. Regenerated each start, so tokens from
-// a previous run stop validating (the web re-resolves the URL on demand).
-func newStreamSecret() []byte {
- b := make([]byte, 32)
- if _, err := rand.Read(b); err != nil {
- // crypto/rand.Read does not fail on supported platforms. If it ever
- // does, fail hard rather than fall back to a predictable key while still
- // claiming to enforce auth — a guessable key is worse than no streaming.
- panic("unarr: crypto/rand unavailable, cannot generate stream secret: " + err.Error())
- }
- return b
-}
-
-// mintStreamToken issues `.` binding scope to an expiry.
-// Verification needs only the same secret + scope.
-func mintStreamToken(secret []byte, scope string, now time.Time) string {
- expStr := strconv.FormatInt(now.Add(streamTokenTTL).Unix(), 10)
- return expStr + "." + streamTokenMAC(secret, scope, expStr)
-}
-
-func streamTokenMAC(secret []byte, scope, expStr string) string {
- m := hmac.New(sha256.New, secret)
- m.Write([]byte(scope + ":" + expStr))
- return hex.EncodeToString(m.Sum(nil))
-}
-
-// verifyStreamToken reports whether token is a valid, unexpired signature for
-// scope under secret. Cheap rejects (format, expiry) happen before the
-// constant-time MAC compare since they don't depend on the secret.
-func verifyStreamToken(secret []byte, scope, token string, now time.Time) bool {
- dot := strings.IndexByte(token, '.')
- if dot <= 0 || dot >= len(token)-1 {
- return false
- }
- expStr, gotMAC := token[:dot], token[dot+1:]
- exp, err := strconv.ParseInt(expStr, 10, 64)
- if err != nil || now.Unix() > exp {
- return false
- }
- wantMAC := streamTokenMAC(secret, scope, expStr)
- return subtle.ConstantTimeCompare([]byte(gotMAC), []byte(wantMAC)) == 1
-}
diff --git a/internal/engine/stream_token_test.go b/internal/engine/stream_token_test.go
deleted file mode 100644
index d5122fa..0000000
--- a/internal/engine/stream_token_test.go
+++ /dev/null
@@ -1,224 +0,0 @@
-package engine
-
-import (
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-)
-
-func TestStreamToken_RoundTrip(t *testing.T) {
- secret := newStreamSecret()
- now := time.Now()
- tok := mintStreamToken(secret, streamScopeStream, now)
- if !verifyStreamToken(secret, streamScopeStream, tok, now) {
- t.Fatalf("freshly minted token failed to verify: %q", tok)
- }
- // Still valid just before expiry.
- if !verifyStreamToken(secret, streamScopeStream, tok, now.Add(streamTokenTTL-time.Minute)) {
- t.Error("token rejected before its TTL elapsed")
- }
-}
-
-func TestStreamToken_Expired(t *testing.T) {
- secret := newStreamSecret()
- now := time.Now()
- tok := mintStreamToken(secret, streamScopeStream, now)
- if verifyStreamToken(secret, streamScopeStream, tok, now.Add(streamTokenTTL+time.Second)) {
- t.Error("expired token verified as valid")
- }
-}
-
-func TestStreamToken_WrongScope(t *testing.T) {
- secret := newStreamSecret()
- now := time.Now()
- tok := mintStreamToken(secret, streamScopeHLS("abc"), now)
- if verifyStreamToken(secret, streamScopeStream, tok, now) {
- t.Error("token for one scope verified under another")
- }
- if verifyStreamToken(secret, streamScopeHLS("xyz"), tok, now) {
- t.Error("hls token verified for a different session id")
- }
- if !verifyStreamToken(secret, streamScopeHLS("abc"), tok, now) {
- t.Error("hls token failed to verify under its own session id")
- }
-}
-
-func TestStreamToken_WrongSecret(t *testing.T) {
- now := time.Now()
- tok := mintStreamToken(newStreamSecret(), streamScopeStream, now)
- if verifyStreamToken(newStreamSecret(), streamScopeStream, tok, now) {
- t.Error("token verified under a different secret")
- }
-}
-
-func TestStreamToken_Tampered(t *testing.T) {
- secret := newStreamSecret()
- now := time.Now()
- tok := mintStreamToken(secret, streamScopeStream, now)
- // Flip the last hex char of the MAC.
- last := tok[len(tok)-1]
- flip := byte('0')
- if last == '0' {
- flip = '1'
- }
- tampered := tok[:len(tok)-1] + string(flip)
- if verifyStreamToken(secret, streamScopeStream, tampered, now) {
- t.Error("tampered MAC verified as valid")
- }
-}
-
-func TestStreamToken_Malformed(t *testing.T) {
- secret := newStreamSecret()
- now := time.Now()
- for _, bad := range []string{
- "",
- "nodot",
- "123.", // empty MAC
- ".deadbeef", // empty exp
- "notanint.abc", // non-numeric exp
- ".",
- } {
- if verifyStreamToken(secret, streamScopeStream, bad, now) {
- t.Errorf("malformed token %q verified as valid", bad)
- }
- }
-}
-
-// TestVerifyStreamToken_CrossLangVector pins the wire format against the web's
-// TypeScript minter (tests/unit/stream-token.test.ts asserts the same vector).
-// A token the web mints MUST verify here or remote HLS playback 404s.
-func TestVerifyStreamToken_CrossLangVector(t *testing.T) {
- secret := make([]byte, 32)
- for i := range secret {
- secret[i] = 0xab // matches secretHex "ab"*32 on the web side
- }
- const (
- sessionID = "sess-1"
- token = "1900000000.3ee840ccf2c2a42b784d7cef68458db1d3cea5ecdcab41061504de32eb52fbc2"
- )
- before := time.Unix(1899978400, 0) // before exp 1900000000
- if !verifyStreamToken(secret, streamScopeHLS(sessionID), token, before) {
- t.Fatal("web-minted parity token failed to verify in the daemon")
- }
- after := time.Unix(1900000001, 0) // past exp
- if verifyStreamToken(secret, streamScopeHLS(sessionID), token, after) {
- t.Error("parity token verified past its expiry")
- }
-}
-
-func TestNewStreamSecret_LengthAndUniqueness(t *testing.T) {
- a, b := newStreamSecret(), newStreamSecret()
- if len(a) != 32 {
- t.Errorf("secret length = %d, want 32", len(a))
- }
- if string(a) == string(b) {
- t.Error("two secrets were identical — not random")
- }
-}
-
-// --- /stream handler enforcement ---------------------------------------------
-
-func streamReq(remoteAddr, query string) *http.Request {
- r := httptest.NewRequest(http.MethodGet, "http://stream.test/stream"+query, nil)
- r.RemoteAddr = remoteAddr
- return r
-}
-
-func newServedServer(t *testing.T) *StreamServer {
- t.Helper()
- srv := NewStreamServer(0)
- srv.SetFile(newFakeProvider("movie.mkv", []byte("fake video bytes")), "task-1")
- return srv
-}
-
-func TestStreamHandler_RemoteWithoutToken_404(t *testing.T) {
- srv := newServedServer(t)
- rec := httptest.NewRecorder()
- srv.handler(rec, streamReq("198.51.100.7:40000", ""))
- if rec.Code != http.StatusNotFound {
- t.Errorf("remote request without token: status = %d, want 404", rec.Code)
- }
-}
-
-func TestStreamHandler_RemoteValidToken_200(t *testing.T) {
- srv := newServedServer(t)
- tok := mintStreamToken(srv.streamSecret, streamScopeStream, time.Now())
- rec := httptest.NewRecorder()
- srv.handler(rec, streamReq("198.51.100.7:40000", "?t="+tok))
- if rec.Code != http.StatusOK {
- t.Errorf("remote request with valid token: status = %d, want 200", rec.Code)
- }
-}
-
-func TestStreamHandler_RemoteBadToken_404(t *testing.T) {
- srv := newServedServer(t)
- rec := httptest.NewRecorder()
- srv.handler(rec, streamReq("198.51.100.7:40000", "?t=deadbeef.0000"))
- if rec.Code != http.StatusNotFound {
- t.Errorf("remote request with bad token: status = %d, want 404", rec.Code)
- }
-}
-
-func TestStreamHandler_LoopbackWithoutToken_404(t *testing.T) {
- // No loopback exemption: cloudflared relays public funnel traffic over
- // localhost, so loopback must still present a valid token.
- srv := newServedServer(t)
- rec := httptest.NewRecorder()
- srv.handler(rec, streamReq("127.0.0.1:55555", ""))
- if rec.Code != http.StatusNotFound {
- t.Errorf("loopback request without token: status = %d, want 404 (no exemption)", rec.Code)
- }
-}
-
-func TestStreamHandler_LoopbackWithValidToken_200(t *testing.T) {
- srv := newServedServer(t)
- tok := mintStreamToken(srv.streamSecret, streamScopeStream, time.Now())
- rec := httptest.NewRecorder()
- srv.handler(rec, streamReq("127.0.0.1:55555", "?t="+tok))
- if rec.Code != http.StatusOK {
- t.Errorf("loopback request with valid token: status = %d, want 200", rec.Code)
- }
-}
-
-func TestStreamHandler_EnforcementOff_NoToken_200(t *testing.T) {
- srv := newServedServer(t)
- srv.SetRequireStreamToken(false)
- rec := httptest.NewRecorder()
- srv.handler(rec, streamReq("198.51.100.7:40000", ""))
- if rec.Code != http.StatusOK {
- t.Errorf("enforcement off: status = %d, want 200", rec.Code)
- }
-}
-
-// --- /hls handler enforcement ------------------------------------------------
-
-func TestHLSHandler_RemoteBadToken_404(t *testing.T) {
- srv := NewStreamServer(0)
- // A syntactically valid session id (UUID-ish) with a bogus token segment.
- const sess = "11111111-1111-4111-8111-111111111111"
- r := httptest.NewRequest(http.MethodGet, "http://stream.test/hls/"+sess+"/badtoken/master.m3u8", nil)
- r.RemoteAddr = "198.51.100.7:40000"
- rec := httptest.NewRecorder()
- srv.hlsHandler(rec, r)
- if rec.Code != http.StatusNotFound {
- t.Errorf("remote hls with bad token: status = %d, want 404", rec.Code)
- }
-}
-
-func TestHLSBaseURLs_CarryTokenSegment(t *testing.T) {
- srv := NewStreamServer(0)
- srv.urls.LAN = "http://192.168.1.2:11818/stream"
- const sess = "22222222-2222-4222-8222-222222222222"
- urls := srv.hlsBaseURLs(sess)
- prefix := "http://192.168.1.2:11818/hls/" + sess + "/"
- if !strings.HasPrefix(urls.LAN, prefix) || len(urls.LAN) <= len(prefix) {
- t.Errorf("hls LAN url = %q, want token segment after %q", urls.LAN, prefix)
- }
- // The trailing segment must be a verifiable hls-scoped token.
- tok := strings.TrimPrefix(urls.LAN, prefix)
- if !verifyStreamToken(srv.streamSecret, streamScopeHLS(sess), tok, time.Now()) {
- t.Errorf("token segment %q does not verify for session %s", tok, sess)
- }
-}
diff --git a/internal/engine/task.go b/internal/engine/task.go
index 2685c6c..09621e8 100644
--- a/internal/engine/task.go
+++ b/internal/engine/task.go
@@ -78,12 +78,6 @@ type Task struct {
ClaimedAt time.Time
StartedAt time.Time
CompletedAt time.Time
-
- // onChange, when set, is called after every successful status Transition so
- // the daemon can push the new state to the server immediately (event-driven
- // uplink) instead of waiting for the next sync tick. Must be non-blocking —
- // it's a coalescing TriggerSync. Set by the Manager at submit time.
- onChange func()
}
// NewTaskFromAgent creates a Task from a server-claimed agent.Task.
@@ -117,15 +111,13 @@ func NewTaskFromAgent(at agent.Task) *Task {
}
}
-// Transition validates and performs a state transition. On success it invokes
-// the onChange hook (outside the lock) so the daemon can push the new state to
-// the server immediately rather than waiting for the next sync tick.
+// Transition validates and performs a state transition.
func (t *Task) Transition(to TaskStatus) error {
t.mu.Lock()
+ defer t.mu.Unlock()
allowed, ok := validTransitions[t.Status]
if !ok {
- t.mu.Unlock()
return fmt.Errorf("no transitions from %s", t.Status)
}
for _, a := range allowed {
@@ -137,28 +129,12 @@ func (t *Task) Transition(to TaskStatus) error {
if to == StatusCompleted || to == StatusFailed {
t.CompletedAt = time.Now()
}
- cb := t.onChange
- t.mu.Unlock()
- // Fire the event-driven uplink AFTER releasing the lock so a future
- // heavier hook can't deadlock on the task mutex.
- if cb != nil {
- cb()
- }
return nil
}
}
- t.mu.Unlock()
return fmt.Errorf("invalid transition: %s -> %s", t.Status, to)
}
-// SetOnChange wires the post-transition hook. Call before the task starts
-// transitioning (the Manager sets it at submit time).
-func (t *Task) SetOnChange(fn func()) {
- t.mu.Lock()
- t.onChange = fn
- t.mu.Unlock()
-}
-
// GetStatus returns current status thread-safely.
func (t *Task) GetStatus() TaskStatus {
t.mu.RLock()
diff --git a/internal/engine/task_test.go b/internal/engine/task_test.go
index e99e339..e9f6ccc 100644
--- a/internal/engine/task_test.go
+++ b/internal/engine/task_test.go
@@ -216,41 +216,3 @@ func TestHasUntried(t *testing.T) {
t.Error("all methods tried")
}
}
-
-func TestTransitionFiresOnChange(t *testing.T) {
- task := NewTaskFromAgent(agent.Task{ID: "t1"}) // StatusClaimed
-
- var fired int
- task.SetOnChange(func() { fired++ })
-
- // Valid transition fires the hook.
- if err := task.Transition(StatusResolving); err != nil {
- t.Fatalf("Transition: %v", err)
- }
- if fired != 1 {
- t.Errorf("onChange fired %d times, want 1 after a valid transition", fired)
- }
-
- // Another valid transition fires again (event-driven, every transition).
- if err := task.Transition(StatusDownloading); err != nil {
- t.Fatalf("Transition: %v", err)
- }
- if fired != 2 {
- t.Errorf("onChange fired %d times, want 2", fired)
- }
-
- // Invalid transition must NOT fire the hook.
- if err := task.Transition(StatusClaimed); err == nil {
- t.Error("expected error on invalid transition downloading→claimed")
- }
- if fired != 2 {
- t.Errorf("onChange fired %d times, want still 2 (no fire on invalid transition)", fired)
- }
-}
-
-func TestTransitionNilOnChangeNoPanic(t *testing.T) {
- task := NewTaskFromAgent(agent.Task{ID: "t2"}) // no onChange set
- if err := task.Transition(StatusResolving); err != nil {
- t.Fatalf("Transition with nil onChange must not error: %v", err)
- }
-}
diff --git a/internal/engine/thumbnail_test.go b/internal/engine/thumbnail_test.go
deleted file mode 100644
index 70be470..0000000
--- a/internal/engine/thumbnail_test.go
+++ /dev/null
@@ -1,199 +0,0 @@
-package engine
-
-import (
- "net/http"
- "net/http/httptest"
- "net/url"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func thumbReq(remoteAddr, query string) *http.Request {
- r := httptest.NewRequest(http.MethodGet, "http://stream.test/thumbnail"+query, nil)
- r.RemoteAddr = remoteAddr
- return r
-}
-
-func indexOfArg(ss []string, target string) int {
- for i, s := range ss {
- if s == target {
- return i
- }
- }
- return -1
-}
-
-// TestStreamScopeThumb_Vector pins the scope string against the web's
-// TypeScript minter (tests/unit/stream-token.test.ts asserts the same vector).
-// A token the web mints for a file MUST reduce to the same scope here or the
-// thumbnail 404s.
-func TestStreamScopeThumb_Vector(t *testing.T) {
- got := streamScopeThumb("/movies/Example (2020)/Example.mkv")
- const want = "thumb:d3f919154ea48832a0b52e4b4ca3e81185ea5f4e2b9e5fece32c6651908cbdd8"
- if got != want {
- t.Fatalf("streamScopeThumb mismatch (web parity broken!): got %q want %q", got, want)
- }
-}
-
-func TestStreamScopeThumb_DistinctPerPath(t *testing.T) {
- a := streamScopeThumb("/a.mkv")
- b := streamScopeThumb("/b.mkv")
- if a == b {
- t.Error("distinct paths produced the same thumb scope")
- }
- if streamScopeThumb("/a.mkv") != a {
- t.Error("same path produced a different thumb scope (not deterministic)")
- }
- if !strings.HasPrefix(a, "thumb:") || len(a) != len("thumb:")+64 {
- t.Errorf("scope %q is not thumb:<64 hex>", a)
- }
-}
-
-func TestBuildThumbnailArgs(t *testing.T) {
- args := buildThumbnailArgs("/x/movie.mkv", 123.5, 320)
- joined := strings.Join(args, " ")
-
- ssIdx, iIdx := indexOfArg(args, "-ss"), indexOfArg(args, "-i")
- if ssIdx < 0 || iIdx < 0 || ssIdx > iIdx {
- t.Errorf("-ss must precede -i (fast input seek): %v", args)
- }
- if args[ssIdx+1] != "123.500" {
- t.Errorf("pos arg = %q, want 123.500", args[ssIdx+1])
- }
- if args[iIdx+1] != "/x/movie.mkv" {
- t.Errorf("input arg = %q, want the path", args[iIdx+1])
- }
- for _, want := range []string{"-frames:v 1", "scale=320:-2", "-f mjpeg", "pipe:1", "-an", "-sn"} {
- if !strings.Contains(joined, want) {
- t.Errorf("args missing %q: %v", want, args)
- }
- }
-}
-
-// buildThumbnailArgsAccurate is the robust fallback used when the fast input
-// seek fails on a file with a corrupt/imprecise seek index (2026-06-03
-// broken-thumbnail bug on anime MKVs). It must use OUTPUT seek (-ss AFTER -i)
-// so it decodes from the start, plus -err_detect ignore_err to tolerate minor
-// stream corruption — the opposite of the fast buildThumbnailArgs.
-func TestBuildThumbnailArgsAccurate(t *testing.T) {
- args := buildThumbnailArgsAccurate("/x/movie.mkv", 123.5, 320)
- joined := strings.Join(args, " ")
-
- ssIdx, iIdx := indexOfArg(args, "-ss"), indexOfArg(args, "-i")
- if ssIdx < 0 || iIdx < 0 || ssIdx <= iIdx {
- t.Errorf("-ss must come AFTER -i (output seek, robust fallback): %v", args)
- }
- if !strings.Contains(joined, "-err_detect ignore_err") {
- t.Errorf("accurate args must tolerate stream errors (-err_detect ignore_err): %v", args)
- }
- if args[ssIdx+1] != "123.500" {
- t.Errorf("pos arg = %q, want 123.500", args[ssIdx+1])
- }
- if args[iIdx+1] != "/x/movie.mkv" {
- t.Errorf("input arg = %q, want the path", args[iIdx+1])
- }
- for _, want := range []string{"-frames:v 1", "scale=320:-2", "-f mjpeg", "pipe:1", "-an", "-sn"} {
- if !strings.Contains(joined, want) {
- t.Errorf("args missing %q: %v", want, args)
- }
- }
-}
-
-func TestParseThumbPos(t *testing.T) {
- cases := map[string]float64{"": 0, "abc": 0, "-5": 0, "0": 0, "12.5": 12.5, "600": 600}
- for in, want := range cases {
- if got := parseThumbPos(in); got != want {
- t.Errorf("parseThumbPos(%q) = %v, want %v", in, got, want)
- }
- }
-}
-
-func TestParseThumbWidth(t *testing.T) {
- cases := map[string]int{"": 320, "abc": 320, "10": 80, "5000": 640, "200": 200, "640": 640, "80": 80}
- for in, want := range cases {
- if got := parseThumbWidth(in); got != want {
- t.Errorf("parseThumbWidth(%q) = %v, want %v", in, got, want)
- }
- }
-}
-
-func TestThumbnailHandler_MissingPath_400(t *testing.T) {
- srv := NewStreamServer(0)
- rec := httptest.NewRecorder()
- srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", ""))
- if rec.Code != http.StatusBadRequest {
- t.Errorf("missing path: status = %d, want 400", rec.Code)
- }
-}
-
-func TestThumbnailHandler_BadToken_404(t *testing.T) {
- srv := NewStreamServer(0)
- rec := httptest.NewRecorder()
- // Path present (so we pass the 400 gate) but a bogus token → 404, no oracle.
- srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", "?p="+url.QueryEscape("/tmp/x.mkv")+"&t=deadbeef.0000"))
- if rec.Code != http.StatusNotFound {
- t.Errorf("bad token: status = %d, want 404", rec.Code)
- }
-}
-
-func TestThumbnailHandler_ValidToken_NonexistentFile_404(t *testing.T) {
- srv := NewStreamServer(0)
- path := "/nonexistent/never-here.mkv"
- tok := mintStreamToken(srv.streamSecret, streamScopeThumb(path), time.Now())
- rec := httptest.NewRecorder()
- srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", "?p="+url.QueryEscape(path)+"&t="+tok))
- if rec.Code != http.StatusNotFound {
- t.Errorf("valid token but missing file: status = %d, want 404 (regular-file clamp)", rec.Code)
- }
-}
-
-func TestThumbnailHandler_NoFFmpeg_503(t *testing.T) {
- srv := NewStreamServer(0) // ffmpegPath left empty
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("not really a video"), 0o600); err != nil {
- t.Fatal(err)
- }
- tok := mintStreamToken(srv.streamSecret, streamScopeThumb(path), time.Now())
- rec := httptest.NewRecorder()
- srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000", "?p="+url.QueryEscape(path)+"&t="+tok))
- if rec.Code != http.StatusServiceUnavailable {
- t.Errorf("no ffmpeg configured: status = %d, want 503", rec.Code)
- }
-}
-
-// TestThumbnailHandler_Success exercises the full success branch with a stub
-// "ffmpeg" that writes JPEG magic bytes to stdout — no real ffmpeg/video
-// needed. Validates 200 + image/jpeg + the body is passed through verbatim.
-func TestThumbnailHandler_Success(t *testing.T) {
- srv := NewStreamServer(0)
- dir := t.TempDir()
- path := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
- t.Fatal(err)
- }
- stub := filepath.Join(dir, "ffmpeg.sh")
- // JPEG SOI marker (FF D8 FF) + filler, regardless of args.
- if err := os.WriteFile(stub, []byte("#!/bin/sh\nprintf '\\377\\330\\377stub'\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- srv.SetFFmpegPath(stub)
-
- tok := mintStreamToken(srv.streamSecret, streamScopeThumb(path), time.Now())
- rec := httptest.NewRecorder()
- srv.thumbnailHandler(rec, thumbReq("198.51.100.7:40000",
- "?p="+url.QueryEscape(path)+"&t="+tok+"&pos=10&w=200"))
-
- if rec.Code != http.StatusOK {
- t.Fatalf("stub ffmpeg: status = %d, want 200 (body=%q)", rec.Code, rec.Body.String())
- }
- if ct := rec.Header().Get("Content-Type"); ct != "image/jpeg" {
- t.Errorf("Content-Type = %q, want image/jpeg", ct)
- }
- if !strings.HasPrefix(rec.Body.String(), "\xff\xd8\xff") {
- t.Errorf("body missing JPEG magic bytes: %q", rec.Body.String())
- }
-}
diff --git a/internal/engine/tonemap.go b/internal/engine/tonemap.go
deleted file mode 100644
index 5a68f8d..0000000
--- a/internal/engine/tonemap.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package engine
-
-import (
- "bytes"
- "context"
- "log"
- "os/exec"
- "strings"
- "sync"
- "time"
-)
-
-// hdrTonemapChain is the ffmpeg filter segment that maps an HDR source
-// (HDR10/HLG, or a Dolby Vision base layer) down to SDR BT.709: linearise the
-// PQ/HLG signal, tonemap the highlights (hable), then re-encode to BT.709
-// transfer/matrix/primaries in limited range. Without it an HDR source
-// transcoded to an SDR encode keeps wide-gamut/PQ data the SDR player can't
-// interpret, so colour looks washed-out / desaturated.
-//
-// Requires the zscale filter (libzimg) in the ffmpeg build — gate on
-// FFmpegSupportsZscale. Trailing comma so it slots in front of the chain's
-// `format=` stage. CPU filter: valid for every encoder here because the decode
-// hwaccel intentionally leaves frames in system memory (see buildHLSFFmpegArgsAt).
-//
-// Tuned for HDR10/PQ (npl=100) and the common DV+HDR10 case. HLG and bare-DV
-// (Profile 5, no PQ signalling) get an approximate mapping — zscale linearises
-// from whatever transfer the stream declares — but the result is still clearly
-// better than the untonemapped washed-out baseline. A per-transfer chain is a
-// possible follow-up if HLG/DV-only sources become common.
-const hdrTonemapChain = "zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,"
-
-// libplaceboTonemapFilter maps an HDR source to SDR BT.709 in a SINGLE GPU pass
-// (Vulkan): tone-map the HDR curve, convert primaries/transfer/matrix to BT.709
-// limited range, and output 8-bit yuv420p — so it REPLACES the zscale chain AND
-// the trailing `format=yuv420p,setparams=bt709` (it does both). Higher quality
-// and far cheaper than the CPU zscale chain, and the agent's ffmpeg has it where
-// zscale is missing. It does NOT scale here — the CPU scale chain runs first
-// (it owns the even-dimension rounding libx264/nvenc require). No trailing comma:
-// it's the last filter in the chain.
-const libplaceboTonemapFilter = "libplacebo=colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv:format=yuv420p:tonemapping=bt.2390"
-
-var (
- zscaleCacheMu sync.Mutex
- zscaleCache = map[string]bool{}
-
- libplaceboCacheMu sync.Mutex
- libplaceboCache = map[string]bool{}
-)
-
-// FFmpegSupportsLibplacebo reports whether this host can ACTUALLY run the
-// libplacebo filter — not merely whether it is compiled in. libplacebo is a
-// Vulkan filter, so it needs a working Vulkan device + ICD at runtime, which a
-// presence check (`ffmpeg -filters`) does NOT prove: the prod agent image
-// ships a BtbN GPL ffmpeg with libplacebo built in but no Vulkan runtime
-// (debian-slim, no libvulkan1 / mesa-vulkan-drivers / nvidia ICD), so a
-// presence check would flip this on and break HDR playback that previously
-// tonemapped fine via zscale.
-//
-// So we run the real filter on one synthetic frame and require a clean exit:
-// that forces Vulkan device creation + filtergraph negotiation (libplacebo
-// auto-inserts the hwupload/hwdownload around itself). Pass → libplacebo works
-// here; fail → fall back to the zscale chain. Cached per path — EXCEPT a
-// context timeout, which is transient (a busy box during the startup warm) and
-// must not pin HDR to zscale for the whole process. The probe is bounded so a
-// wedged ffmpeg can't stall the first session.
-func FFmpegSupportsLibplacebo(ffmpegPath string) bool {
- if ffmpegPath == "" {
- return false
- }
- libplaceboCacheMu.Lock()
- if v, ok := libplaceboCache[ffmpegPath]; ok {
- libplaceboCacheMu.Unlock()
- return v
- }
- libplaceboCacheMu.Unlock()
-
- // 10 s: first-run Vulkan device creation alone can take ~1 s ("Spent
- // ~1150ms creating vulkan device"), plus codec/filter init.
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- // Run the EXACT filter we'd use, on a 1-frame synthetic source, discarding
- // output. testsrc2 is SDR so the tonemap is near-passthrough — the point is
- // to exercise Vulkan device init + the filter, not the mapping quality.
- out, err := exec.CommandContext(ctx, ffmpegPath,
- "-hide_banner", "-loglevel", "error", "-nostats",
- "-f", "lavfi", "-i", "testsrc2=size=128x128:rate=1:duration=1",
- "-vf", libplaceboTonemapFilter, "-frames:v", "1", "-f", "null", "-",
- ).CombinedOutput()
- supported := err == nil
-
- // Cache the result — but NOT a timeout. A clean non-zero exit (filter
- // absent, no Vulkan ICD) is a stable "no" worth remembering; a deadline is
- // transient (the box was busy, e.g. the startup warm racing the encode
- // benchmark) and caching it would force HDR onto the zscale CPU chain until
- // restart. Worst case a perpetually-loaded box re-probes per session — rare,
- // and it fails closed to zscale each time.
- if supported || ctx.Err() != context.DeadlineExceeded {
- libplaceboCacheMu.Lock()
- libplaceboCache[ffmpegPath] = supported
- libplaceboCacheMu.Unlock()
- }
- if supported {
- log.Printf("[tonemap] ffmpeg libplacebo works (Vulkan OK) — HDR sources tonemapped on the GPU (preferred)")
- } else {
- // On an exec/timeout failure the stderr tail is empty — surface err
- // itself so the log distinguishes "no Vulkan" from "ffmpeg never ran".
- detail := strings.TrimSpace(lastLine(out))
- if detail == "" {
- detail = err.Error()
- }
- log.Printf("[tonemap] ffmpeg libplacebo unavailable (no Vulkan runtime or filter absent) — HDR falls back to zscale/none: %v", detail)
- }
- return supported
-}
-
-// lastLine returns the last non-empty line of ffmpeg output — the actual error
-// (e.g. "No VK_ICD..." / "Device creation failed") rather than the whole log.
-func lastLine(b []byte) string {
- lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
- for i := len(lines) - 1; i >= 0; i-- {
- if strings.TrimSpace(lines[i]) != "" {
- return lines[i]
- }
- }
- return ""
-}
-
-// FFmpegSupportsZscale reports whether the ffmpeg binary at path was built with
-// the zscale filter (libzimg), required for HDR→SDR tonemapping. Cached per
-// path. A detection failure (binary missing, exec error) is treated as "no" so
-// tonemapping is simply skipped — the source still plays, just without it.
-func FFmpegSupportsZscale(ffmpegPath string) bool {
- if ffmpegPath == "" {
- return false
- }
- zscaleCacheMu.Lock()
- if v, ok := zscaleCache[ffmpegPath]; ok {
- zscaleCacheMu.Unlock()
- return v
- }
- zscaleCacheMu.Unlock()
-
- // Probe OUTSIDE the lock: `ffmpeg -filters` can take a beat, and holding the
- // mutex across it would stall a concurrent session start. Worst case two
- // cold callers probe the same binary at once — both write the same bool.
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- out, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-filters").Output()
- supported := err == nil && bytes.Contains(out, []byte("zscale"))
-
- zscaleCacheMu.Lock()
- zscaleCache[ffmpegPath] = supported
- zscaleCacheMu.Unlock()
- if supported {
- log.Printf("[tonemap] ffmpeg has zscale — HDR sources will be tonemapped to SDR")
- } else {
- log.Printf("[tonemap] ffmpeg %q lacks zscale — HDR sources play without tonemapping (desaturated)", ffmpegPath)
- }
- return supported
-}
diff --git a/internal/engine/tonemap_test.go b/internal/engine/tonemap_test.go
deleted file mode 100644
index ce5f3ff..0000000
--- a/internal/engine/tonemap_test.go
+++ /dev/null
@@ -1,176 +0,0 @@
-package engine
-
-import (
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "testing"
-)
-
-func hlsArgsFor(hdr string, tonemap bool, hw HWAccel) string {
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourcePath: "/movies/x.mkv",
- Quality: "720p",
- Transcode: TranscodeRuntime{
- FFmpegPath: "/usr/bin/ffmpeg",
- FFprobePath: "/usr/bin/ffprobe",
- HWAccel: hw,
- TonemapHDR: tonemap,
- },
- }
- probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: hdr, DurationSec: 100}
- return strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " ")
-}
-
-func vfChain(joined string) string {
- parts := strings.Split(joined, " ")
- for i, p := range parts {
- if p == "-vf" && i+1 < len(parts) {
- return parts[i+1]
- }
- }
- return ""
-}
-
-func TestTonemap_AppliedForHDRWhenSupported(t *testing.T) {
- vf := vfChain(hlsArgsFor("HDR10", true, HWAccelNone))
- if !strings.Contains(vf, "zscale=t=linear") || !strings.Contains(vf, "tonemap=tonemap=hable") {
- t.Fatalf("HDR + zscale-capable: expected tonemap in -vf, got %q", vf)
- }
- // Order: a scale filter, then tonemap (zscale), then format=.
- scaleIdx := strings.Index(vf, "scale=")
- zIdx := strings.Index(vf, "zscale=t=linear")
- fmtIdx := strings.Index(vf, "format=")
- if !(scaleIdx >= 0 && scaleIdx < zIdx && zIdx < fmtIdx) {
- t.Errorf("filter order wrong (scale < tonemap < format): %q", vf)
- }
-}
-
-func TestTonemap_AppliedInNoDownscaleBranch(t *testing.T) {
- // Source already within the quality cap → no downscale; tonemap must still
- // be inserted before format=.
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourcePath: "/movies/x.mkv",
- Quality: "2160p",
- Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNone, TonemapHDR: true},
- }
- probe := &StreamProbe{Width: 3840, Height: 2160, HDR: "HDR10", DurationSec: 100}
- vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " "))
- if !strings.Contains(vf, "tonemap=tonemap=hable") {
- t.Errorf("no-downscale branch: expected tonemap, got %q", vf)
- }
- if z, f := strings.Index(vf, "zscale=t=linear"), strings.Index(vf, "format="); !(z >= 0 && z < f) {
- t.Errorf("tonemap must precede format=: %q", vf)
- }
-}
-
-func TestTonemap_LibplaceboPreferredOverZscale(t *testing.T) {
- // HDR source + an ffmpeg with libplacebo on a REAL HW encoder (NVENC) → the
- // single GPU filter replaces the whole CPU zscale chain (and the trailing
- // format=/setparams it folds in). NVENC (not None) because libplacebo is
- // gated on a real GPU — a software encoder stays on zscale.
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourcePath: "/movies/x.mkv",
- Quality: "720p",
- Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNVENC, TonemapHDR: true, HasLibplacebo: true},
- }
- probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: "HDR10", DurationSec: 100}
- vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " "))
- if !strings.Contains(vf, "libplacebo") {
- t.Fatalf("libplacebo-capable ffmpeg: expected libplacebo filter, got %q", vf)
- }
- if strings.Contains(vf, "zscale=t=linear") || strings.Contains(vf, "tonemap=tonemap=hable") {
- t.Errorf("libplacebo must replace the zscale chain, not run alongside it: %q", vf)
- }
-}
-
-func TestTonemap_LibplaceboSkippedOnSoftwareEncoder(t *testing.T) {
- // libplacebo present but no HW encoder (software libx264) → must NOT use
- // libplacebo: a software host's only Vulkan would be lavapipe (CPU), slower
- // than zscale. Falls back to the zscale chain.
- cfg := HLSSessionConfig{
- SessionID: "test",
- SourcePath: "/movies/x.mkv",
- Quality: "720p",
- Transcode: TranscodeRuntime{FFmpegPath: "/usr/bin/ffmpeg", HWAccel: HWAccelNone, TonemapHDR: true, HasLibplacebo: true},
- }
- probe := &StreamProbe{Width: 3840, Height: 2160, BitDepth: 10, HDR: "HDR10", DurationSec: 100}
- vf := vfChain(strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/t", 0, 0), " "))
- if strings.Contains(vf, "libplacebo") {
- t.Errorf("software encoder must not use libplacebo (lavapipe trap), got %q", vf)
- }
- if !strings.Contains(vf, "zscale=t=linear") {
- t.Errorf("software encoder with HDR + zscale should fall back to the zscale chain, got %q", vf)
- }
-}
-
-func TestTonemap_SkippedWhenFFmpegLacksZscale(t *testing.T) {
- vf := vfChain(hlsArgsFor("HDR10", false, HWAccelNone))
- if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") {
- t.Errorf("ffmpeg without zscale: tonemap must be skipped, got %q", vf)
- }
-}
-
-func TestTonemap_SkippedForSDR(t *testing.T) {
- vf := vfChain(hlsArgsFor("", true, HWAccelNone))
- if strings.Contains(vf, "zscale") || strings.Contains(vf, "tonemap") {
- t.Errorf("SDR source: no tonemap expected, got %q", vf)
- }
-}
-
-func TestTonemap_VAAPIInsertsBeforeHwupload(t *testing.T) {
- vf := vfChain(hlsArgsFor("HDR10", true, HWAccelVAAPI))
- if !strings.Contains(vf, "tonemap=tonemap=hable") {
- t.Fatalf("VAAPI HDR: expected tonemap, got %q", vf)
- }
- // Tonemap is a CPU filter — must run before the GPU upload.
- if up := strings.Index(vf, "hwupload"); up >= 0 {
- if strings.Index(vf, "zscale=t=linear") > up {
- t.Errorf("tonemap must precede hwupload: %q", vf)
- }
- }
-}
-
-func TestFFmpegSupportsLibplacebo_FunctionalProbe(t *testing.T) {
- if FFmpegSupportsLibplacebo("") {
- t.Error("empty path must be false")
- }
- // A bogus path can't run → false (no panic, no hang).
- if FFmpegSupportsLibplacebo("/nonexistent/ffmpeg") {
- t.Error("nonexistent ffmpeg must be false")
- }
- // With a real ffmpeg the result is environment-dependent (true only when a
- // Vulkan runtime is present), so we only assert the probe completes and
- // returns a bool — its whole purpose is to be honest about THIS host.
- if _, err := exec.LookPath("ffmpeg"); err == nil {
- _ = FFmpegSupportsLibplacebo("ffmpeg") // must not hang or panic
- }
-}
-
-func TestFFmpegSupportsZscale_Stub(t *testing.T) {
- dir := t.TempDir()
-
- withZ := filepath.Join(dir, "ffmpeg-with.sh")
- if err := os.WriteFile(withZ, []byte("#!/bin/sh\necho ' .SC zscale V->V'\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- if !FFmpegSupportsZscale(withZ) {
- t.Error("expected true for an ffmpeg whose -filters lists zscale")
- }
-
- noZ := filepath.Join(dir, "ffmpeg-without.sh")
- if err := os.WriteFile(noZ, []byte("#!/bin/sh\necho ' ... scale V->V'\n"), 0o755); err != nil {
- t.Fatal(err)
- }
- if FFmpegSupportsZscale(noZ) {
- t.Error("expected false for an ffmpeg whose -filters omits zscale")
- }
-
- if FFmpegSupportsZscale("") {
- t.Error("empty path must be false")
- }
-}
diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go
index acd1838..f4b1b6d 100644
--- a/internal/engine/torrent.go
+++ b/internal/engine/torrent.go
@@ -16,30 +16,12 @@ import (
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage"
- "github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term"
"golang.org/x/time/rate"
)
-// portfwdFilterHandler wraps anacrolix/log handlers and drops the noisy
-// UPnP/NAT-PMP port-mapping warnings (e.g. "error: AddPortMapping: 500 Internal
-// Server Error") that home routers emit when they reject the mapping. Everything
-// else passes through unchanged.
-type portfwdFilterHandler struct {
- inner []alog.Handler
-}
-
-func (h portfwdFilterHandler) Handle(r alog.Record) {
- if strings.Contains(r.Text(), "AddPortMapping") {
- return
- }
- for _, inner := range h.inner {
- inner.Handle(r)
- }
-}
-
var defaultTrackers = []string{
// Tier 1: ngosang/trackerslist "best" + newtrackon "stable"
"udp://tracker.opentrackr.org:1337/announce",
@@ -79,21 +61,16 @@ var defaultTrackers = []string{
// TorrentConfig holds settings for the BitTorrent downloader.
type TorrentConfig struct {
- DataDir string
- // PieceCompletionDir, when non-empty, stores the piece-completion SQLite DB
- // in this directory instead of DataDir. Use the agent's local state dir
- // (not the download dir) so the DB never lands on NFS/SMB volumes where
- // SQLite locking times out.
- PieceCompletionDir string
- MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited)
- StallTimeout time.Duration // no progress during download for this long = stall (default 10m)
- MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
- MaxDownloadRate int64 // bytes/s, 0 = unlimited
- MaxUploadRate int64 // bytes/s, 0 = unlimited
- ListenPort int // fixed port for incoming peers (default 42069, 0 = random)
- SeedEnabled bool
- SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
- SeedTime time.Duration // min seed time after completion (default 0)
+ DataDir string
+ MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited)
+ StallTimeout time.Duration // no progress during download for this long = stall (default 10m)
+ MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
+ MaxDownloadRate int64 // bytes/s, 0 = unlimited
+ MaxUploadRate int64 // bytes/s, 0 = unlimited
+ ListenPort int // fixed port for incoming peers (default 42069, 0 = random)
+ SeedEnabled bool
+ SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
+ SeedTime time.Duration // min seed time after completion (default 0)
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
@@ -108,24 +85,8 @@ type TorrentDownloader struct {
activeMu sync.Mutex
active map[string]*torrent.Torrent // taskID -> torrent handle
-
- // seedCtx scopes the background seeders. Cancelled at Shutdown so they stop
- // uploading and exit; it must outlive any single download's task context
- // (which is cancelled the moment Download returns and the queue slot frees).
- seedCtx context.Context
- seedCancel context.CancelFunc
- // seedCheckInterval is how often the background seeder re-checks its stop
- // condition. Defaults to defaultSeedCheckInterval; tests lower it.
- seedCheckInterval time.Duration
-
- minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
}
-// SetMinFreeBytes sets the free-space reserve enforced before a download starts.
-// Call once at construction; 0 disables the reserve (the size-vs-free check still
-// runs). See CheckDiskSpace.
-func (d *TorrentDownloader) SetMinFreeBytes(n int64) { d.minFreeBytes = n }
-
// NewTorrentDownloader creates a BitTorrent downloader with a long-lived client.
func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// MetadataTimeout: 0 = unlimited (wait forever like qBittorrent)
@@ -143,16 +104,6 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg.Seed = cfg.SeedEnabled
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
- // Drop the noisy UPnP/NAT-PMP port-mapping warnings. The library attempts to
- // map the listen port on the router for inbound peers (best-effort, only
- // helps on routers that support it). Many home routers reject AddPortMapping
- // with "500 Internal Server Error" and the lib retries on every lease cycle,
- // spamming the log. The rejection is harmless (download works over DHT +
- // outbound peers), so suppress just that line while keeping the attempts for
- // routers that do support it.
- tcfg.Logger.SetHandlers(portfwdFilterHandler{
- inner: append([]alog.Handler(nil), alog.Default.Handlers...),
- })
// No browser-facing WebTorrent peer; daemon never seeds via WSS.
tcfg.DisableWebtorrent = true
@@ -162,23 +113,7 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// Storage: mmap instead of default file backend.
// The library author notes file storage has "very high system overhead".
// mmap improves I/O throughput and piece verification speed significantly.
- //
- // When PieceCompletionDir is set (daemon always passes the agent state dir),
- // keep the piece-completion SQLite DB off the download dir so it never lands
- // on NFS/SMB where SQLite's file locking times out and emits a warning.
- if cfg.PieceCompletionDir != "" {
- if mkErr := os.MkdirAll(cfg.PieceCompletionDir, 0o755); mkErr != nil {
- log.Printf("[torrent] piece-completion dir create failed (%v), DB stays in download dir", mkErr)
- tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
- } else if pc, pcErr := storage.NewDefaultPieceCompletionForDir(cfg.PieceCompletionDir); pcErr != nil {
- log.Printf("[torrent] piece-completion db in %q failed (%v), falling back to download dir", cfg.PieceCompletionDir, pcErr)
- tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
- } else {
- tcfg.DefaultStorage = storage.NewMMapWithCompletion(cfg.DataDir, pc)
- }
- } else {
- tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
- }
+ tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
// Fixed port for incoming peer connections (enables UPnP port mapping).
// With ListenPort=0, only ~30% of peers can connect to us.
@@ -315,14 +250,10 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
}
}
- seedCtx, seedCancel := context.WithCancel(context.Background())
return &TorrentDownloader{
- client: client,
- cfg: cfg,
- active: make(map[string]*torrent.Torrent),
- seedCtx: seedCtx,
- seedCancel: seedCancel,
- seedCheckInterval: defaultSeedCheckInterval,
+ client: client,
+ cfg: cfg,
+ active: make(map[string]*torrent.Torrent),
}, nil
}
@@ -345,11 +276,14 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
d.active[task.ID] = t
d.activeMu.Unlock()
- // cleanup drops the torrent and stops tracking it. Used by every error path
- // (metadata timeout, disk guard, poll failure) and by the non-seeding success
- // path — all of which must drop. The seeding success path deliberately does
- // NOT call cleanup (it hands off to seedAndDrop).
- cleanup := func() { d.dropTracked(task.ID, t) }
+ cleanup := func() {
+ d.activeMu.Lock()
+ delete(d.active, task.ID)
+ d.activeMu.Unlock()
+ if !d.cfg.SeedEnabled {
+ t.Drop()
+ }
+ }
// 1. Wait for metadata (0 = unlimited, like qBittorrent)
if d.cfg.MetadataTimeout > 0 {
@@ -385,15 +319,6 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
log.Printf("[%s] downloading %s (%s)", task.ID[:8], fileName, formatBytes(totalBytes))
- // 2.5 Pre-flight disk-space guard — refuse before writing rather than fill
- // the disk to 0 mid-download (corrupts the partial file). Torrents land in
- // DataDir (not the manager's outputDir), so stat DataDir. Conservative: uses
- // the full selected size without subtracting pieces a resume may already hold.
- if err := CheckDiskSpace(d.cfg.DataDir, totalBytes, d.minFreeBytes); err != nil {
- cleanup()
- return nil, err
- }
-
// 3. Poll progress with stall detection
result, err := d.pollDownload(ctx, t, task, totalBytes, fileName, progressCh)
if err != nil {
@@ -427,21 +352,9 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
result.Method = MethodTorrent
result.Size = totalBytes
- // anacrolix mmap storage (storage.NewMMap) creates completed files with mode
- // 0000 — the running process keeps its own mmap handle so the download works,
- // but any fresh open (streaming, ffprobe/HLS, organize-then-reopen) hits
- // "permission denied". Relax perms now, before organize moves the file, so the
- // readable mode is preserved through the rename.
- makeReadable(filePath)
-
- // Seeding handoff: with seeding enabled, keep the torrent uploading in the
- // background — seedAndDrop drops it once the ratio/time target is hit (or at
- // shutdown). Otherwise drop now. seedAndDrop must NOT use ctx: the task
- // context is cancelled the moment Download returns and the manager frees the
- // queue slot, which would kill the seeder instantly.
- if d.cfg.SeedEnabled {
- go d.seedAndDrop(task.ID, t, totalBytes)
- } else {
+ // If seeding enabled, keep alive (don't cleanup).
+ // The manager handles seeding lifecycle.
+ if !d.cfg.SeedEnabled {
cleanup()
}
@@ -546,163 +459,6 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
}
}
-// dropTracked stops tracking taskID and drops the torrent handle. The delete is
-// guarded on the entry still being this handle, so a concurrent Pause/Cancel that
-// already removed/replaced it isn't clobbered; t.Drop() is idempotent. Shared by
-// the error/non-seeding cleanup path and the post-seeding drop.
-func (d *TorrentDownloader) dropTracked(taskID string, t *torrent.Torrent) {
- d.activeMu.Lock()
- if cur, ok := d.active[taskID]; ok && cur == t {
- delete(d.active, taskID)
- }
- d.activeMu.Unlock()
- t.Drop()
-}
-
-// defaultSeedCheckInterval is how often the background seeder re-evaluates the
-// ratio / time stop condition. Seeding is long-running and low-urgency, so a
-// coarse interval keeps the overhead negligible. Stored on the downloader so
-// tests can lower it.
-const defaultSeedCheckInterval = 30 * time.Second
-
-// seedTargetReached reports why seeding should stop, or "" to keep going.
-// Ratio is uploaded-data / selected-size ("uploaded N× the content"), which is
-// stable across resumes — unlike uploaded/downloaded-this-session. The two
-// targets are independent: whichever of ratio (>0) or time (>0) fires first
-// wins; with both unset nothing ever fires (the caller seeds indefinitely).
-func seedTargetReached(ratioTarget float64, timeTarget time.Duration, uploaded, size int64, elapsed time.Duration) string {
- var ratio float64
- if size > 0 {
- ratio = float64(uploaded) / float64(size)
- }
- switch {
- case ratioTarget > 0 && ratio >= ratioTarget:
- return fmt.Sprintf("ratio %.2f reached (target %.2f)", ratio, ratioTarget)
- case timeTarget > 0 && elapsed >= timeTarget:
- return fmt.Sprintf("seed time %s reached (target %s)", elapsed.Round(time.Second), timeTarget)
- }
- return ""
-}
-
-// seedAndDrop keeps a completed torrent uploading until the configured ratio or
-// time target is reached, then drops it (stops seeding, releases the handle and
-// its queue tracking). Runs detached on d.seedCtx — see the Download call site
-// for why it can't use the task context. With no ratio/time target it returns
-// immediately and the torrent seeds until Shutdown (or a user cancel/pause drops
-// it). It exits without dropping if the handle was already removed elsewhere, so
-// it never reads stats off a closed torrent nor double-drops.
-func (d *TorrentDownloader) seedAndDrop(taskID string, t *torrent.Torrent, totalBytes int64) {
- sid := agent.ShortID(taskID)
-
- ratioTarget := d.cfg.SeedRatio
- timeTarget := d.cfg.SeedTime
- if ratioTarget <= 0 && timeTarget <= 0 {
- log.Printf("[%s] seeding indefinitely (no ratio/time target) — drops at shutdown", sid)
- return
- }
- log.Printf("[%s] seeding (ratio target: %.2f, time target: %s)", sid, ratioTarget, timeTarget)
-
- interval := d.seedCheckInterval
- if interval <= 0 {
- interval = defaultSeedCheckInterval
- }
- start := time.Now()
- ticker := time.NewTicker(interval)
- defer ticker.Stop()
-
- for {
- select {
- case <-d.seedCtx.Done():
- return // daemon shutting down — Shutdown drops the handle
- case <-ticker.C:
- // Bail if the handle was dropped elsewhere (user cancel/pause).
- d.activeMu.Lock()
- cur, ok := d.active[taskID]
- d.activeMu.Unlock()
- if !ok || cur != t {
- return
- }
-
- stats := t.Stats()
- uploaded := stats.BytesWrittenData.Int64()
- reason := seedTargetReached(ratioTarget, timeTarget, uploaded, totalBytes, time.Since(start))
- if reason == "" {
- continue
- }
-
- log.Printf("[%s] seeding complete: %s, uploaded %s — dropping", sid, reason, formatBytes(uploaded))
- d.dropTracked(taskID, t)
- return
- }
- }
-}
-
-// makeReadable relaxes permissions on a completed download so it can be
-// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
-// files with mode 0000; we set files to 0644 and directories to 0755. Best
-// effort + non-fatal — but a chmod that fails (typically NFS root_squash / SMB
-// uid mapping) is surfaced with a clear, actionable WARNING instead of leaving
-// the file 0000 to produce a cryptic "permission denied" later in the pipeline.
-func makeReadable(path string) {
- info, err := os.Stat(path)
- if err != nil {
- log.Printf("[organize] makeReadable stat %q: %v", path, err)
- return
- }
- if !info.IsDir() {
- if err := os.Chmod(path, 0o644); err != nil {
- log.Printf("[organize] makeReadable chmod %q: %v", path, err)
- }
- // Verify the file is actually openable now — on NFS/SMB the chmod may
- // "succeed" yet leave it unreadable to this uid. Catch it here with a
- // pointed message rather than as an opaque error at stream/probe time.
- warnIfUnreadable(path)
- return
- }
- var chmodFails int
- var firstFile string
- err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error {
- if walkErr != nil {
- return nil // skip unreadable entries, keep going
- }
- mode := os.FileMode(0o644)
- if d.IsDir() {
- mode = 0o755
- } else if firstFile == "" {
- firstFile = p
- }
- if err := os.Chmod(p, mode); err != nil {
- chmodFails++
- log.Printf("[organize] makeReadable chmod %q: %v", p, err)
- }
- return nil
- })
- if err != nil {
- log.Printf("[organize] makeReadable walk %q: %v", path, err)
- }
- if chmodFails > 0 {
- log.Printf("[organize] WARNING: %d file(s) under %q could not be made readable (chmod failed) — likely NFS root_squash or an SMB uid mapping. Streaming, ffprobe and organize will fail to open them. Run the agent as the user that owns the share, or mount it so that user can chmod.", chmodFails, path)
- }
- // Same silent-unreadable check the single-file path does: on NFS/SMB a chmod
- // can "succeed" yet leave the file unopenable. Probe one representative file
- // so the directory path catches that case too, not only outright chmod errors.
- if firstFile != "" {
- warnIfUnreadable(firstFile)
- }
-}
-
-// warnIfUnreadable logs a clear, actionable warning when a file we just chmod'd
-// still can't be opened for reading — the anacrolix-mmap-0000 + NFS/SMB failure
-// mode. Best effort: it neither fails the download nor blocks delivery.
-func warnIfUnreadable(path string) {
- f, err := os.Open(path)
- if err != nil {
- log.Printf("[organize] WARNING: %q is not readable after chmod (%v) — likely NFS root_squash or an SMB uid mapping (anacrolix mmap creates files mode 0000). Streaming/ffprobe/organize will fail. Run the agent as the user that owns the share, or mount it so that user can chmod.", path, err)
- return
- }
- _ = f.Close()
-}
-
// Pause drops the torrent handle but keeps partial files on disk for resume.
func (d *TorrentDownloader) Pause(taskID string) error {
d.activeMu.Lock()
@@ -753,12 +509,6 @@ func (d *TorrentDownloader) Cancel(taskID string) error {
}
func (d *TorrentDownloader) Shutdown(ctx context.Context) error {
- // Stop background seeders first so they don't read stats off / re-drop the
- // handles we're about to close.
- if d.seedCancel != nil {
- d.seedCancel()
- }
-
// Save DHT nodes in binary format for next session (warm start)
saveDhtNodesBinary(d.client)
@@ -813,10 +563,7 @@ func (d *TorrentDownloader) GetStreamProvider(taskID string) (FileProvider, erro
return nil, fmt.Errorf("torrent has no files")
}
- // The provider probes the bitrate asynchronously (to size the streaming
- // readahead) — passing DataDir lets it locate the on-disk file without
- // blocking stream start.
- return NewTorrentFileProvider(video, d.cfg.DataDir), nil
+ return NewTorrentFileProvider(video), nil
}
// VideoExts is the canonical set of video file extensions used for file selection.
@@ -905,15 +652,12 @@ func formatBytes(b int64) string {
if b < unit {
return fmt.Sprintf("%d B", b)
}
- // Cap exp at the last unit so an exabyte-scale value (or a corrupt/huge
- // size) can never index past the slice and panic.
- units := []string{"KB", "MB", "GB", "TB", "PB", "EB"}
div, exp := int64(unit), 0
- for n := b / unit; n >= unit && exp < len(units)-1; n /= unit {
+ for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
- return fmt.Sprintf("%.1f %s", float64(b)/float64(div), units[exp])
+ return fmt.Sprintf("%.1f %s", float64(b)/float64(div), []string{"KB", "MB", "GB", "TB"}[exp])
}
// ---------------------------------------------------------------------------
diff --git a/internal/engine/torrent_test.go b/internal/engine/torrent_test.go
index 156123f..a785651 100644
--- a/internal/engine/torrent_test.go
+++ b/internal/engine/torrent_test.go
@@ -2,59 +2,10 @@ package engine
import (
"context"
- "os"
- "path/filepath"
"testing"
"time"
)
-// TestMakeReadable_FixesZeroMode verifies makeReadable turns an unreadable
-// mode-0000 file (the anacrolix mmap default) into a readable 0644 one.
-func TestMakeReadable_FixesZeroMode(t *testing.T) {
- dir := t.TempDir()
- p := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(p, []byte("x"), 0o000); err != nil {
- t.Fatal(err)
- }
- if f, err := os.Open(p); err == nil {
- f.Close()
- t.Skip("running as root — 0000 files are readable; can't exercise the fix")
- }
- makeReadable(p)
- f, err := os.Open(p)
- if err != nil {
- t.Fatalf("file still unreadable after makeReadable: %v", err)
- }
- f.Close()
- if fi, _ := os.Stat(p); fi.Mode().Perm() != 0o644 {
- t.Errorf("mode = %o, want 0644", fi.Mode().Perm())
- }
-}
-
-// TestMakeReadable_DirWalk verifies the directory branch relaxes a 0000 file
-// nested inside the download dir.
-func TestMakeReadable_DirWalk(t *testing.T) {
- dir := t.TempDir()
- sub := filepath.Join(dir, "Release")
- if err := os.MkdirAll(sub, 0o755); err != nil {
- t.Fatal(err)
- }
- p := filepath.Join(sub, "movie.mkv")
- if err := os.WriteFile(p, []byte("x"), 0o000); err != nil {
- t.Fatal(err)
- }
- if f, err := os.Open(p); err == nil {
- f.Close()
- t.Skip("running as root — 0000 files are readable")
- }
- makeReadable(sub)
- f, err := os.Open(p)
- if err != nil {
- t.Fatalf("nested file unreadable after makeReadable: %v", err)
- }
- f.Close()
-}
-
// TestNewTorrentDownloader_ValidConfig verifica que se puede crear un downloader
// con una configuración válida sin errores.
func TestNewTorrentDownloader_ValidConfig(t *testing.T) {
@@ -313,114 +264,3 @@ func TestTorrentDownloader_DownloadTimeout_MetadataCancel(t *testing.T) {
func TestTorrentDownloader_ImplementsInterface(t *testing.T) {
var _ Downloader = (*TorrentDownloader)(nil)
}
-
-// TestSeedTargetReached cubre la lógica pura de parada del seeding: ratio,
-// tiempo, ninguno, ambos (el primero que se cumple gana) y la guarda de tamaño
-// cero (no debe dividir por cero ni parar por ratio).
-func TestSeedTargetReached(t *testing.T) {
- tests := []struct {
- name string
- ratioTarget float64
- timeTarget time.Duration
- uploaded int64
- size int64
- elapsed time.Duration
- wantStop bool
- }{
- {"ratio reached", 2.0, 0, 200, 100, time.Minute, true},
- {"ratio not reached", 2.0, 0, 150, 100, time.Minute, false},
- {"ratio exactly met", 1.0, 0, 100, 100, time.Minute, true},
- {"time reached", 0, time.Hour, 10, 100, 2 * time.Hour, true},
- {"time not reached", 0, time.Hour, 10, 100, 30 * time.Minute, false},
- {"no targets never stops", 0, 0, 9999, 100, 99 * time.Hour, false},
- {"ratio wins when both set", 2.0, time.Hour, 200, 100, time.Second, true},
- {"time wins when ratio short", 5.0, time.Hour, 100, 100, 2 * time.Hour, true},
- {"zero size guards div", 2.0, 0, 200, 0, time.Minute, false},
- }
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- reason := seedTargetReached(tc.ratioTarget, tc.timeTarget, tc.uploaded, tc.size, tc.elapsed)
- if got := reason != ""; got != tc.wantStop {
- t.Errorf("seedTargetReached(ratio=%.1f time=%s up=%d size=%d el=%s) stop=%v (reason %q), want %v",
- tc.ratioTarget, tc.timeTarget, tc.uploaded, tc.size, tc.elapsed, got, reason, tc.wantStop)
- }
- })
- }
-}
-
-// TestTorrentDownloader_SeedRatioTime verifica que SeedRatio y SeedTime se
-// propagan a la config del downloader.
-func TestTorrentDownloader_SeedRatioTime(t *testing.T) {
- dir := t.TempDir()
- dl, err := NewTorrentDownloader(TorrentConfig{
- DataDir: dir,
- SeedEnabled: true,
- SeedRatio: 1.5,
- SeedTime: 2 * time.Hour,
- })
- if err != nil {
- t.Fatalf("NewTorrentDownloader: %v", err)
- }
- defer dl.Shutdown(context.Background())
-
- if dl.cfg.SeedRatio != 1.5 {
- t.Errorf("SeedRatio = %v, want 1.5", dl.cfg.SeedRatio)
- }
- if dl.cfg.SeedTime != 2*time.Hour {
- t.Errorf("SeedTime = %v, want 2h", dl.cfg.SeedTime)
- }
- if dl.seedCtx == nil || dl.seedCancel == nil {
- t.Error("seedCtx/seedCancel must be initialised by the constructor")
- }
-}
-
-// TestSeedAndDrop_NoTargetReturnsImmediately verifica que sin ratio ni tiempo
-// objetivo, seedAndDrop retorna de inmediato (siembra indefinida) sin tocar el
-// handle — por eso es seguro pasar un torrent nil.
-func TestSeedAndDrop_NoTargetReturnsImmediately(t *testing.T) {
- dir := t.TempDir()
- dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir, SeedEnabled: true}) // ratio 0, time 0
- if err != nil {
- t.Fatalf("NewTorrentDownloader: %v", err)
- }
- defer dl.Shutdown(context.Background())
-
- done := make(chan struct{})
- go func() {
- dl.seedAndDrop("no-target-task-id", nil, 1000)
- close(done)
- }()
-
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("seedAndDrop with no target should return immediately")
- }
-}
-
-// TestSeedAndDrop_StopsOnSeedCtxCancel verifica que seedAndDrop sale cuando se
-// cancela seedCtx (ruta de Shutdown), incluso con un objetivo de ratio alto y el
-// tick deshabilitado — el único camino de salida es seedCtx.Done().
-func TestSeedAndDrop_StopsOnSeedCtxCancel(t *testing.T) {
- dir := t.TempDir()
- dl, err := NewTorrentDownloader(TorrentConfig{DataDir: dir, SeedEnabled: true, SeedRatio: 99})
- if err != nil {
- t.Fatalf("NewTorrentDownloader: %v", err)
- }
- defer dl.Shutdown(context.Background())
-
- dl.seedCheckInterval = time.Hour // el ticker no disparará; solo seedCtx.Done() puede terminar
- dl.seedCancel() // cancela antes de arrancar el monitor
-
- done := make(chan struct{})
- go func() {
- dl.seedAndDrop("ctx-cancel-task-id", nil, 1000)
- close(done)
- }()
-
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("seedAndDrop should return when seedCtx is cancelled")
- }
-}
diff --git a/internal/engine/transcode_quality.go b/internal/engine/transcode_quality.go
index fe05683..4efda59 100644
--- a/internal/engine/transcode_quality.go
+++ b/internal/engine/transcode_quality.go
@@ -1,10 +1,5 @@
package engine
-import (
- "math"
- "strconv"
-)
-
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
@@ -19,19 +14,6 @@ type TranscodeRuntime struct {
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
- // TonemapHDR enables HDR→SDR tonemapping of HDR sources during transcode.
- // Set only when the ffmpeg build has zscale (FFmpegSupportsZscale); without
- // it the tonemap filter would error and break playback, so it stays off.
- TonemapHDR bool
- // HasLibplacebo: the ffmpeg build has the libplacebo filter (GPU HDR tonemap).
- // Preferred over the zscale chain for HDR sources — one GPU pass, higher
- // quality, and present where zscale is missing.
- HasLibplacebo bool
- // HasScaleCuda: this host can run scale_cuda (CUDA device + filter). Lets an
- // NVENC downscale of an SDR source stay fully on the GPU (decode → scale_cuda
- // → h264_nvenc) instead of round-tripping each frame to the CPU for `scale=`.
- // Probed functionally (FFmpegSupportsScaleCuda); false ⇒ keep the CPU scale.
- HasScaleCuda bool
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
@@ -58,35 +40,6 @@ func resolveQualityCap(label string) qualityCap {
}
}
-// doubleBitrate returns an ffmpeg bitrate string with twice the value of the
-// input ("6000k" → "12000k", "1.5M" → "3M", "5M" → "10M"). Used to size
-// `-bufsize` at the standard 2× of `-maxrate` for capped-CRF/CQ rate control.
-// An unparseable string falls back to the input unchanged (1× bufsize — the
-// pre-CRF behaviour, safe just suboptimal). The doubled CPB stays far below
-// every H.264 level's limit for the (level, maxrate) pairs this package emits
-// (worst case: 1080p level 4.1 → 12000k bufsize vs 62500k allowed).
-func doubleBitrate(b string) string {
- if b == "" {
- return b
- }
- num := b
- suffix := ""
- switch b[len(b)-1] {
- case 'k', 'K', 'm', 'M':
- num = b[:len(b)-1]
- suffix = string(b[len(b)-1])
- }
- v, err := strconv.ParseFloat(num, 64)
- if err != nil || v <= 0 {
- return b
- }
- d := v * 2
- if d == math.Trunc(d) {
- return strconv.FormatFloat(d, 'f', 0, 64) + suffix
- }
- return strconv.FormatFloat(d, 'f', -1, 64) + suffix
-}
-
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots
diff --git a/internal/engine/transcoder.go b/internal/engine/transcoder.go
index 5a1fff2..030c28c 100644
--- a/internal/engine/transcoder.go
+++ b/internal/engine/transcoder.go
@@ -27,11 +27,6 @@ type TranscodeOpts struct {
SourceHeight int // probed source height — used to derive a sane H.264 level
StartSeconds float64
FFmpegPath string
- // VideoTag forces the output stream's codec tag on a copy remux. HEVC muxed
- // into MP4 must carry the `hvc1` tag (not the default `hev1`) or Safari /
- // Apple devices refuse to decode it. Empty = leave ffmpeg's default. Only
- // applied on copy actions (passthrough/remux); a real re-encode sets its own.
- VideoTag string
}
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
@@ -227,16 +222,8 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
switch opts.Action {
case ActionPassthrough, ActionRemux:
args = append(args, "-c:v", "copy", "-c:a", "copy")
- // HEVC → MP4 needs the hvc1 tag for Apple/Safari (hueco #3 / 3c).
- if opts.VideoTag != "" {
- args = append(args, "-tag:v", opts.VideoTag)
- }
case ActionRemuxAudio:
- args = append(args, "-c:v", "copy")
- if opts.VideoTag != "" {
- args = append(args, "-tag:v", opts.VideoTag) // HEVC → hvc1 for Apple
- }
- args = append(args, "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
+ args = append(args, "-c:v", "copy", "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k"))
case ActionTranscodeVideo:
videoCodec := opts.HWAccel.FFmpegVideoCodec("h264")
args = append(args, "-c:v", videoCodec)
@@ -302,16 +289,8 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
// until the whole mdat lands and playback never starts.
// * negative_cts_offsets: lets b-frames carry the right pts/dts so
// decoders don't reset the playhead to 0 every fragment.
- // * delay_moov: a re-encoded AAC track starts with a negative dts (the
- // encoder priming/delay). With empty_moov the moov is written up front,
- // BEFORE that delay is known, so the first fragment is malformed and a
- // strict demuxer (Safari, and any browser the way Apple decodes HEVC)
- // never initialises playback — the loads bytes but never starts,
- // and the player re-bootstraps the session every few seconds. delay_moov
- // holds the moov until the first packet so the priming dts is handled.
- // (Was the "remux loads in Network but won't play" bug.)
args = append(args,
- "-movflags", "+frag_keyframe+empty_moov+default_base_moof+negative_cts_offsets+delay_moov",
+ "-movflags", "+frag_keyframe+empty_moov+default_base_moof+negative_cts_offsets",
"-frag_duration", "1000000",
"-f", "mp4",
"pipe:1",
diff --git a/internal/engine/usenet.go b/internal/engine/usenet.go
index b8c26ce..c39be86 100644
--- a/internal/engine/usenet.go
+++ b/internal/engine/usenet.go
@@ -42,15 +42,8 @@ type UsenetDownloader struct {
// Cached NZB search results (from Available → Download)
nzbCache map[string]*agent.NzbSearchResult // taskID → best result
nzbCacheMu sync.RWMutex
-
- minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
}
-// SetMinFreeBytes sets the free-space reserve enforced before a download starts.
-// Call once at construction; 0 disables the reserve (the size-vs-free check still
-// runs). See CheckDiskSpace.
-func (u *UsenetDownloader) SetMinFreeBytes(n int64) { u.minFreeBytes = n }
-
// NewUsenetDownloader creates a usenet downloader.
// apiClient is used to call the web API for NZB search, download, and credentials.
func NewUsenetDownloader(apiClient *agent.Client) *UsenetDownloader {
@@ -178,12 +171,6 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
if resumed {
log.Printf("[%s] resuming usenet download (%d/%d segments completed)",
shortID, tracker.TotalCompleted(), totalSegs)
- } else {
- // Pre-flight disk-space guard on a fresh download (a resume already has
- // its partial bytes on disk; ENOSPC stays the backstop there).
- if err := CheckDiskSpace(outputDir, totalBytes, u.minFreeBytes); err != nil {
- return nil, err
- }
}
// Always flush progress on exit — covers graceful shutdown, SIGTERM,
@@ -276,11 +263,6 @@ func (u *UsenetDownloader) Download(ctx context.Context, task *Task, outputDir s
if ppResult.Extracted {
log.Printf("[%s] extracted archive", shortID)
}
- if ppResult.VerifyNote != "" {
- // Degraded verification (par2 missing / repair failed): surface it loudly
- // so the delivered file isn't silently assumed good.
- log.Printf("[%s] WARNING: %s", shortID, ppResult.VerifyNote)
- }
finalPath := ppResult.FinalPath
if finalPath == "" {
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 a89371c..dd07516 100644
--- a/internal/engine/validate.go
+++ b/internal/engine/validate.go
@@ -21,32 +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 (, ) do not send the Origin
// header so they are not gated by CORS at all; this allowlist only
// affects fetch()/XHR.
var defaultCORSAllowedOrigins = []string{
"https://torrentclaw.com",
- "https://www.torrentclaw.com",
"https://app.torrentclaw.com",
- "https://staging.torrentclaw.com",
- "https://torrentclaw.to",
- "https://www.torrentclaw.to",
- // unarr brand (separate deployment). The web player + agent endpoints run
- // under unarr.app; without these the browser drops every /hls + /stream
- // response (no Access-Control-Allow-Origin) and playback fails on unarr.
- "https://unarr.app",
- "https://www.unarr.app",
- // Tor mirror — Tor Browser sends `Origin: http://.onion` (plain
- // http, no port). Mirror address is the BUILT_IN_ONION constant from
- // torrentclaw-web/src/lib/mirrors-config.ts; rotates rarely, kept in
- // sync by hand. Daemon also dynamically merges /api/mirrors at startup
- // (see daemon.go) so a new key doesn't need a CLI rebuild.
- "http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion",
"http://localhost:3030",
"http://127.0.0.1:3030",
}
diff --git a/internal/funnel/funnel.go b/internal/funnel/funnel.go
index bb379c3..6a8640a 100644
--- a/internal/funnel/funnel.go
+++ b/internal/funnel/funnel.go
@@ -12,9 +12,9 @@
//
// Lifecycle:
//
-// t, err := funnel.Start(ctx, funnel.Config{Port: 11819})
-// defer t.Close()
-// url, err := t.WaitURL(30 * time.Second) // blocks until cloudflared emits the URL
+// t, err := funnel.Start(ctx, funnel.Config{Port: 11819})
+// defer t.Close()
+// url, err := t.WaitURL(30 * time.Second) // blocks until cloudflared emits the URL
//
// The tunnel runs until the context is cancelled or t.Close() is called.
package funnel
@@ -32,13 +32,9 @@ import (
)
// urlPattern matches the `https://.trycloudflare.com` URL cloudflared
-// prints when a Quick Tunnel is registered. Quick Tunnel hostnames are always
-// several hyphen-joined dictionary words (e.g.
-// `make-appointments-negotiation-blacks`), so we require at least one hyphen.
-// This deliberately excludes cloudflared's control-plane endpoint
-// `https://api.trycloudflare.com`, which appears earlier in the log stream — a
-// permissive `[a-z0-9-]+` matched `api` first and we advertised a dead URL.
-var urlPattern = regexp.MustCompile(`https://[a-z0-9]+(?:-[a-z0-9]+)+\.trycloudflare\.com`)
+// prints when a Quick Tunnel is registered. The hostname has a random
+// hyphen-separated label followed by .trycloudflare.com.
+var urlPattern = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`)
// Config controls how the tunnel is launched.
type Config struct {
@@ -200,3 +196,4 @@ func (t *Tunnel) scanStderr(r io.Reader) {
}
}
}
+
diff --git a/internal/funnel/funnel_test.go b/internal/funnel/funnel_test.go
deleted file mode 100644
index fa9280d..0000000
--- a/internal/funnel/funnel_test.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package funnel
-
-import "testing"
-
-func TestURLPattern(t *testing.T) {
- cases := []struct {
- name string
- line string
- want string
- }{
- {
- name: "real quick tunnel banner",
- line: "2026-05-29T22:18:33Z INF | https://make-appointments-negotiation-blacks.trycloudflare.com |",
- want: "https://make-appointments-negotiation-blacks.trycloudflare.com",
- },
- {
- name: "two-word hostname",
- line: "https://blue-river.trycloudflare.com is ready",
- want: "https://blue-river.trycloudflare.com",
- },
- {
- name: "control-plane api endpoint is ignored",
- line: `2026-05-29T22:17:59Z DBG POST https://api.trycloudflare.com/tunnel`,
- want: "",
- },
- {
- name: "no trycloudflare url",
- line: "2026-05-29T22:17:44Z INF Requesting new quick Tunnel on trycloudflare.com...",
- want: "",
- },
- }
-
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- if got := urlPattern.FindString(tc.line); got != tc.want {
- t.Fatalf("FindString(%q) = %q, want %q", tc.line, got, tc.want)
- }
- })
- }
-}
diff --git a/internal/funnel/install.go b/internal/funnel/install.go
index 401ee8c..1e827a4 100644
--- a/internal/funnel/install.go
+++ b/internal/funnel/install.go
@@ -2,8 +2,6 @@ package funnel
import (
"bytes"
- "crypto/sha256"
- "encoding/hex"
"errors"
"fmt"
"io"
@@ -12,34 +10,11 @@ import (
"os/exec"
"path/filepath"
"runtime"
- "strings"
"time"
"github.com/torrentclaw/unarr/internal/config"
)
-// pinnedCloudflaredVersion is the exact cloudflared release the auto-downloader
-// fetches. We deliberately do NOT track `latest`: pinning a version we vetted +
-// verifying its SHA-256 is what bounds the supply-chain risk (a future malicious
-// or breaking upstream release can't be pulled silently). Operator-installed
-// cloudflared on $PATH always wins, so this only affects the headless
-// auto-download fallback.
-//
-// To bump: pick a newer tag, copy its per-asset SHA-256 from the release body
-// (https://github.com/cloudflare/cloudflared/releases/tag/) into the
-// map below, and update this constant. All four arch entries MUST be present.
-const pinnedCloudflaredVersion = "2026.5.2"
-
-// pinnedCloudflaredSHA256 maps each linux asset to its SHA-256 for
-// pinnedCloudflaredVersion (from the release body — Cloudflare publishes the
-// hashes inline there, not as a separate file or signature).
-var pinnedCloudflaredSHA256 = map[string]string{
- "cloudflared-linux-amd64": "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886",
- "cloudflared-linux-arm64": "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815",
- "cloudflared-linux-armhf": "190152c373f608080eb6aa9e2aad395f88398dfb9efd0f9b064e2652cffcefdd",
- "cloudflared-linux-386": "ad82d1dbed8bbb9d702807cbd97df932cc774d29e9da5c109b7a3c7f7aee2065",
-}
-
// ResolveBinary returns the path to a usable cloudflared executable, downloading
// one into the unarr data dir if neither $PATH nor the cached location has it.
// This makes the funnel feature usable on headless installs (NAS / Docker)
@@ -70,19 +45,19 @@ func cachedBinaryPath() string {
return filepath.Join(config.DataDir(), "bin", name)
}
-// downloadCloudflared fetches the PINNED cloudflared release asset matching the
-// current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a pointer
-// at the OS package manager.
+// downloadCloudflared fetches the latest cloudflared release asset matching
+// the current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a
+// pointer at the OS package manager.
//
-// Integrity: the fetch is HTTPS (bounded by Let's Encrypt + GitHub's cert
-// chain) AND the downloaded bytes are verified against a baked-in SHA-256 for
-// the pinned version (pinnedCloudflaredSHA256). A mismatch — corruption, MITM
-// past TLS, a swapped asset — is rejected before the binary is promoted or run.
-// Because the version is pinned (not `latest`), a future malicious/breaking
-// upstream release is never pulled silently. The cheap ELF/size check still
-// runs first to reject a 404 HTML page before hashing 50 MB. For stricter
-// control, install cloudflared via your distro package manager — the PATH copy
-// always takes precedence.
+// Supply-chain caveat: we trust GitHub-over-TLS + cloudflare/cloudflared
+// repo integrity. The fetch is over HTTPS to api.github.com's release-asset
+// redirector, so a network MITM is bounded by Let's Encrypt + GitHub's cert
+// chain. We additionally verify the file is an ELF binary (Linux magic
+// bytes) so a generic 404 HTML page or a wrong-arch tarball is rejected at
+// rest. We do NOT verify a signature because Cloudflare doesn't sign release
+// assets at the moment — if you need stricter integrity, install cloudflared
+// from your distro's package manager (apt/brew/winget) and unarr will use
+// the PATH copy.
func downloadCloudflared(dest string) (string, error) {
if runtime.GOOS != "linux" {
return "", fmt.Errorf("funnel: auto-download not supported on %s — install cloudflared manually or drop a binary at %s", runtime.GOOS, dest)
@@ -102,12 +77,7 @@ func downloadCloudflared(dest string) (string, error) {
return "", fmt.Errorf("funnel: unsupported linux arch %q — install cloudflared manually", runtime.GOARCH)
}
- expectedSHA, ok := pinnedCloudflaredSHA256[asset]
- if !ok {
- return "", fmt.Errorf("funnel: no pinned SHA-256 for asset %q (bug: keep pinnedCloudflaredSHA256 in sync with the arch switch)", asset)
- }
-
- url := "https://github.com/cloudflare/cloudflared/releases/download/" + pinnedCloudflaredVersion + "/" + asset
+ url := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + asset
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("funnel: create bin dir: %w", err)
}
@@ -155,22 +125,14 @@ func downloadCloudflared(dest string) (string, error) {
return "", fmt.Errorf("funnel: close dest: %w", err)
}
- // Cheap reject first: must be a Linux ELF executable (rejects 404 HTML pages
- // or wrong-arch payloads) and at least 1 MB, so we don't hash 50 MB of an
- // obviously-wrong file.
+ // Sanity check before promoting to : must be a Linux
+ // ELF executable (rejects 404 HTML pages or wrong-arch payloads) and at
+ // least 1 MB (real cloudflared is ~50 MB; anything smaller is corrupt).
if err := verifyLinuxElf(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: downloaded file failed sanity check: %w", err)
}
- // Authoritative integrity gate: the bytes must match the SHA-256 we baked in
- // for the pinned version. Rejects corruption, a MITM past TLS, or a swapped
- // asset before the binary is ever promoted or executed.
- if err := verifySHA256(tmp, expectedSHA); err != nil {
- _ = os.Remove(tmp)
- return "", fmt.Errorf("funnel: cloudflared %s integrity check failed: %w", pinnedCloudflaredVersion, err)
- }
-
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: rename dest: %w", err)
@@ -203,22 +165,3 @@ func verifyLinuxElf(path string) error {
}
return nil
}
-
-// verifySHA256 returns nil when the file at `path` hashes to expectedHex
-// (case-insensitive), else an error reporting both digests.
-func verifySHA256(path, expectedHex string) error {
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer f.Close()
- h := sha256.New()
- if _, err := io.Copy(h, f); err != nil {
- return fmt.Errorf("hashing: %w", err)
- }
- got := hex.EncodeToString(h.Sum(nil))
- if !strings.EqualFold(got, expectedHex) {
- return fmt.Errorf("sha256 mismatch: got %s, want %s", got, expectedHex)
- }
- return nil
-}
diff --git a/internal/funnel/install_test.go b/internal/funnel/install_test.go
deleted file mode 100644
index 461d7c4..0000000
--- a/internal/funnel/install_test.go
+++ /dev/null
@@ -1,62 +0,0 @@
-package funnel
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "os"
- "path/filepath"
- "testing"
-)
-
-// TestVerifySHA256 covers the integrity gate used on the auto-downloaded
-// cloudflared binary: it accepts the matching digest (case-insensitive) and
-// rejects a wrong one.
-func TestVerifySHA256(t *testing.T) {
- dir := t.TempDir()
- path := filepath.Join(dir, "blob")
- content := []byte("cloudflared-bytes")
- if err := os.WriteFile(path, content, 0o644); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(content)
- good := hex.EncodeToString(sum[:])
-
- if err := verifySHA256(path, good); err != nil {
- t.Errorf("verifySHA256(correct) = %v, want nil", err)
- }
- // Upper-case should still match.
- if err := verifySHA256(path, good[:60]+"ABCD"); err == nil {
- t.Error("verifySHA256(wrong) = nil, want mismatch error")
- }
- if err := verifySHA256(path, "deadbeef"); err == nil {
- t.Error("verifySHA256(short/wrong) = nil, want error")
- }
-}
-
-// TestPinnedCloudflaredSHA256Complete guards the invariant that every linux arch
-// the downloader can select has a pinned 64-hex SHA-256, so a download never
-// reaches the verify step without an expected digest.
-func TestPinnedCloudflaredSHA256Complete(t *testing.T) {
- wantAssets := []string{
- "cloudflared-linux-amd64",
- "cloudflared-linux-arm64",
- "cloudflared-linux-armhf",
- "cloudflared-linux-386",
- }
- for _, a := range wantAssets {
- sum, ok := pinnedCloudflaredSHA256[a]
- if !ok {
- t.Errorf("missing pinned SHA-256 for %q", a)
- continue
- }
- if len(sum) != 64 {
- t.Errorf("%s: SHA-256 length = %d, want 64", a, len(sum))
- }
- if _, err := hex.DecodeString(sum); err != nil {
- t.Errorf("%s: SHA-256 not valid hex: %v", a, err)
- }
- }
- if pinnedCloudflaredVersion == "" {
- t.Error("pinnedCloudflaredVersion must be set")
- }
-}
diff --git a/internal/library/fingerprint.go b/internal/library/fingerprint.go
deleted file mode 100644
index 525fadf..0000000
--- a/internal/library/fingerprint.go
+++ /dev/null
@@ -1,55 +0,0 @@
-package library
-
-import (
- "crypto/sha256"
- "encoding/binary"
- "encoding/hex"
- "io"
- "os"
-)
-
-// fpChunk is how many bytes are hashed from the head and the tail of a file.
-const fpChunk = 1 << 20 // 1 MiB
-
-// ComputeFingerprint returns a stable content identity for a media file:
-// sha256(fileSize ‖ first 1 MiB ‖ last 1 MiB). It survives renames, moves, and
-// base-path changes (unlike the absolute path), so the server can recognise the
-// same file at a new location and move its library row in place instead of
-// duplicating it. Cheap: two bounded reads, never the whole file (except small
-// ones). See docs/plans/unarr-path-resilience.md in the web repo.
-func ComputeFingerprint(path string, size int64) (string, error) {
- f, err := os.Open(path)
- if err != nil {
- return "", err
- }
- defer f.Close()
-
- h := sha256.New()
- var sizeBuf [8]byte
- binary.LittleEndian.PutUint64(sizeBuf[:], uint64(size))
- h.Write(sizeBuf[:])
-
- if size <= 2*fpChunk {
- // Small file: hash it whole — head+tail would overlap anyway.
- if _, err := io.Copy(h, f); err != nil {
- return "", err
- }
- } else {
- head := make([]byte, fpChunk)
- if _, err := io.ReadFull(f, head); err != nil {
- return "", err
- }
- h.Write(head)
-
- if _, err := f.Seek(size-fpChunk, io.SeekStart); err != nil {
- return "", err
- }
- tail := make([]byte, fpChunk)
- if _, err := io.ReadFull(f, tail); err != nil {
- return "", err
- }
- h.Write(tail)
- }
-
- return hex.EncodeToString(h.Sum(nil)), nil
-}
diff --git a/internal/library/fingerprint_test.go b/internal/library/fingerprint_test.go
deleted file mode 100644
index e8b5d20..0000000
--- a/internal/library/fingerprint_test.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package library
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func writeFile(t *testing.T, dir, name string, data []byte) string {
- t.Helper()
- p := filepath.Join(dir, name)
- if err := os.WriteFile(p, data, 0o644); err != nil {
- t.Fatalf("write %s: %v", p, err)
- }
- return p
-}
-
-func fp(t *testing.T, path string) string {
- t.Helper()
- fi, err := os.Stat(path)
- if err != nil {
- t.Fatalf("stat %s: %v", path, err)
- }
- s, err := ComputeFingerprint(path, fi.Size())
- if err != nil {
- t.Fatalf("fingerprint %s: %v", path, err)
- }
- return s
-}
-
-func TestComputeFingerprint(t *testing.T) {
- dir := t.TempDir()
- big := make([]byte, 5<<20) // 5 MiB > 2*chunk
- for i := range big {
- big[i] = byte(i * 7)
- }
-
- a := fp(t, writeFile(t, dir, "a.bin", big))
- if len(a) != 64 {
- t.Fatalf("want 64-hex, got %d", len(a))
- }
-
- // Move-invariance: identical bytes at a different path → same fingerprint.
- if b := fp(t, writeFile(t, dir, "moved.bin", big)); b != a {
- t.Errorf("move changed fingerprint: %s != %s", a, b)
- }
-
- // Tail sensitivity: flipping the last byte must change the fingerprint.
- tailMut := append([]byte(nil), big...)
- tailMut[len(tailMut)-1] ^= 0xFF
- if c := fp(t, writeFile(t, dir, "tail.bin", tailMut)); c == a {
- t.Error("tail mutation did not change fingerprint")
- }
-
- // Head sensitivity.
- headMut := append([]byte(nil), big...)
- headMut[0] ^= 0xFF
- if c := fp(t, writeFile(t, dir, "head.bin", headMut)); c == a {
- t.Error("head mutation did not change fingerprint")
- }
-
- // Size is mixed in: a small file and a large file never collide trivially.
- small := fp(t, writeFile(t, dir, "small.bin", []byte("hello world")))
- if small == a {
- t.Error("small and big fingerprints collided")
- }
-}
-
-func TestRelToRoot(t *testing.T) {
- cases := []struct{ root, full, want string }{
- {"/downloads", "/downloads/TV Shows/X/S01E09.mkv", "TV Shows/X/S01E09.mkv"},
- {"/downloads", "/mnt/other/file.mkv", ""}, // outside root
- {"/downloads", "/downloads", ""}, // equal → "."
- {"", "/x/y.mkv", ""}, // no root
- }
- for _, c := range cases {
- if got := relToRoot(c.root, c.full); got != c.want {
- t.Errorf("relToRoot(%q,%q)=%q want %q", c.root, c.full, got, c.want)
- }
- }
-}
diff --git a/internal/library/loadgate_test.go b/internal/library/loadgate_test.go
deleted file mode 100644
index 8b7c505..0000000
--- a/internal/library/loadgate_test.go
+++ /dev/null
@@ -1,46 +0,0 @@
-package library
-
-import (
- "context"
- "testing"
- "time"
-
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
-)
-
-// A huge ratio means the threshold is always above the real load, so the gate
-// must return immediately (no blocking) regardless of how busy the box is.
-func TestWaitForLowLoad_HighRatioReturnsImmediately(t *testing.T) {
- done := make(chan struct{})
- go func() {
- waitForLowLoad(context.Background(), 1e9)
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("waitForLowLoad blocked despite an impossibly-high threshold")
- }
-}
-
-// With a tiny ratio the gate would block (load almost always exceeds it), but a
-// cancelled context must unblock it promptly — the prewarm has to stop cleanly on
-// Ctrl-C / daemon shutdown even while waiting for the machine to go idle.
-func TestWaitForLowLoad_RespectsContextCancel(t *testing.T) {
- if _, ok := mediainfo.LoadAverage1(); !ok {
- t.Skip("no load reading on this platform — gate is a no-op")
- }
- ctx, cancel := context.WithCancel(context.Background())
- cancel() // already cancelled
-
- done := make(chan struct{})
- go func() {
- waitForLowLoad(ctx, 0.0001) // threshold ~0 → would otherwise block
- close(done)
- }()
- select {
- case <-done:
- case <-time.After(2 * time.Second):
- t.Fatal("waitForLowLoad ignored a cancelled context")
- }
-}
diff --git a/internal/library/mediainfo/charset.go b/internal/library/mediainfo/charset.go
deleted file mode 100644
index 68bac3c..0000000
--- a/internal/library/mediainfo/charset.go
+++ /dev/null
@@ -1,139 +0,0 @@
-package mediainfo
-
-import (
- "bytes"
- "strings"
- "unicode/utf8"
-
- "golang.org/x/text/encoding"
- "golang.org/x/text/encoding/charmap"
- "golang.org/x/text/encoding/japanese"
- "golang.org/x/text/encoding/korean"
- "golang.org/x/text/encoding/simplifiedchinese"
- "golang.org/x/text/encoding/traditionalchinese"
- "golang.org/x/text/encoding/unicode"
- "golang.org/x/text/transform"
-)
-
-// Subtitle charset normalisation.
-//
-// External subtitle files are routinely NOT UTF-8: legacy .srt files come in the
-// uploader's local code page (Windows-1252 Western, Windows-1256 Arabic, GBK
-// Chinese, Shift-JIS Japanese, …). Feeding those raw to ffmpeg → WebVTT yields
-// mojibake. We detect the encoding and transcode to UTF-8 before extraction.
-//
-// Detection order: BOM (authoritative) → valid UTF-8 → a code page chosen from
-// the track's declared language (from its filename, e.g. ".ar.srt"). The
-// language hint is the reliable signal we have without a full statistical
-// detector: an Arabic sub that isn't UTF-8 is almost certainly Windows-1256, a
-// Russian one Windows-1251, and so on. Western European is the safe default.
-
-// legacyEncodingForLang returns the most likely single-byte / CJK encoding for a
-// non-UTF-8 subtitle in the given language hint. The hint is normally an ISO
-// 639-1 code, but Chinese carries a script suffix ("zh-hant" / "zh-tw") so a
-// Traditional sidecar decodes as Big5 instead of GBK (decoding Big5 bytes as GBK
-// is mojibake — and anime fansubs routinely ship both chs AND cht). Default:
-// Windows-1252.
-func legacyEncodingForLang(lang string) encoding.Encoding {
- switch strings.ToLower(strings.TrimSpace(lang)) {
- case "ar", "fa", "ur": // Arabic script
- return charmap.Windows1256
- case "ru", "uk", "bg", "sr", "mk": // Cyrillic
- return charmap.Windows1251
- case "el": // Greek
- return charmap.Windows1253
- case "he": // Hebrew
- return charmap.Windows1255
- case "tr": // Turkish
- return charmap.Windows1254
- case "th": // Thai
- return charmap.Windows874
- case "zh-hant", "zh_hant", "zh-tw", "zh-hk", "zhtw": // Traditional Chinese
- return traditionalchinese.Big5
- case "zh", "zh-hans", "zh-cn": // Simplified Chinese (covers most pirate releases)
- return simplifiedchinese.GBK
- case "ja": // Japanese
- return japanese.ShiftJIS
- case "ko": // Korean
- return korean.EUCKR
- case "vi": // Vietnamese
- return charmap.Windows1258
- case "pl", "cs", "sk", "hu", "ro", "hr", "sl": // Central European
- return charmap.Windows1250
- case "lt", "lv", "et": // Baltic
- return charmap.Windows1257
- default: // Western European + everything else
- return charmap.Windows1252
- }
-}
-
-// DecodeSubtitleToUTF8 returns the bytes as UTF-8, transcoding from a detected
-// legacy encoding when needed. The returned name is for logging ("utf-8",
-// "bom-utf16le", "windows-1256", …). Never fails: a transcode error falls back
-// to the original bytes (ffmpeg may still cope).
-func DecodeSubtitleToUTF8(data []byte, langHint string) ([]byte, string) {
- // BOM wins — it's unambiguous.
- switch {
- case bytes.HasPrefix(data, []byte{0xEF, 0xBB, 0xBF}):
- return data[3:], "bom-utf8"
- case bytes.HasPrefix(data, []byte{0xFF, 0xFE}):
- return decodeWith(data, unicode.UTF16(unicode.LittleEndian, unicode.UseBOM), "bom-utf16le")
- case bytes.HasPrefix(data, []byte{0xFE, 0xFF}):
- return decodeWith(data, unicode.UTF16(unicode.BigEndian, unicode.UseBOM), "bom-utf16be")
- }
- // Already valid UTF-8 → no transcode (ASCII is a subset, so plain English
- // srt files hit this).
- if utf8.Valid(data) {
- return data, "utf-8"
- }
- // Non-UTF-8: transcode from the language's likely code page.
- enc := legacyEncodingForLang(langHint)
- out, name := decodeWith(data, enc, encodingName(enc))
- return out, name
-}
-
-// decodeWith transforms data through enc's decoder to UTF-8. On error returns the
-// original bytes (best-effort) with the name suffixed "(raw)".
-func decodeWith(data []byte, enc encoding.Encoding, name string) ([]byte, string) {
- out, _, err := transform.Bytes(enc.NewDecoder(), data)
- if err != nil || len(out) == 0 {
- return data, name + "(raw)"
- }
- return out, name
-}
-
-// encodingName maps a known encoding back to a short label for logs.
-func encodingName(enc encoding.Encoding) string {
- switch enc {
- case charmap.Windows1250:
- return "windows-1250"
- case charmap.Windows1251:
- return "windows-1251"
- case charmap.Windows1252:
- return "windows-1252"
- case charmap.Windows1253:
- return "windows-1253"
- case charmap.Windows1254:
- return "windows-1254"
- case charmap.Windows1255:
- return "windows-1255"
- case charmap.Windows1256:
- return "windows-1256"
- case charmap.Windows1257:
- return "windows-1257"
- case charmap.Windows1258:
- return "windows-1258"
- case charmap.Windows874:
- return "windows-874"
- case simplifiedchinese.GBK:
- return "gbk"
- case traditionalchinese.Big5:
- return "big5"
- case japanese.ShiftJIS:
- return "shift-jis"
- case korean.EUCKR:
- return "euc-kr"
- default:
- return "legacy"
- }
-}
diff --git a/internal/library/mediainfo/charset_test.go b/internal/library/mediainfo/charset_test.go
deleted file mode 100644
index c3672b1..0000000
--- a/internal/library/mediainfo/charset_test.go
+++ /dev/null
@@ -1,64 +0,0 @@
-package mediainfo
-
-import (
- "testing"
-
- "golang.org/x/text/encoding/charmap"
- "golang.org/x/text/transform"
-)
-
-func TestDecodeSubtitleToUTF8_PlainASCII(t *testing.T) {
- in := []byte("Hello world")
- out, name := DecodeSubtitleToUTF8(in, "en")
- if string(out) != "Hello world" || name != "utf-8" {
- t.Fatalf("ASCII passthrough failed: %q %s", out, name)
- }
-}
-
-func TestDecodeSubtitleToUTF8_BOMStripped(t *testing.T) {
- in := append([]byte{0xEF, 0xBB, 0xBF}, []byte("café")...)
- out, name := DecodeSubtitleToUTF8(in, "fr")
- if string(out) != "café" || name != "bom-utf8" {
- t.Fatalf("UTF-8 BOM strip failed: %q %s", out, name)
- }
-}
-
-func TestDecodeSubtitleToUTF8_Windows1252(t *testing.T) {
- // "café" encoded in Windows-1252 (é = 0xE9) is NOT valid UTF-8.
- enc1252, _, err := transform.Bytes(charmap.Windows1252.NewEncoder(), []byte("café"))
- if err != nil {
- t.Fatal(err)
- }
- out, name := DecodeSubtitleToUTF8(enc1252, "fr")
- if string(out) != "café" {
- t.Fatalf("Windows-1252 decode failed: got %q (%s)", out, name)
- }
- if name != "windows-1252" {
- t.Fatalf("expected windows-1252, got %s", name)
- }
-}
-
-func TestDecodeSubtitleToUTF8_TraditionalChineseBig5(t *testing.T) {
- // 繁 (U+7E41) in Big5 is 0xC1 0x63. Decoding it as GBK would be mojibake, so
- // the zh-Hant hint must route to Big5.
- in := []byte{0xC1, 0x63}
- out, name := DecodeSubtitleToUTF8(in, "zh-Hant")
- if name != "big5" {
- t.Fatalf("expected big5 for zh-Hant, got %s", name)
- }
- if string(out) != "繁" {
- t.Fatalf("Big5 decode failed: got %q", out)
- }
-}
-
-func TestDecodeSubtitleToUTF8_ArabicByLang(t *testing.T) {
- // Arabic letter ا (U+0627) is 0xC7 in Windows-1256.
- in := []byte{0xC7}
- out, name := DecodeSubtitleToUTF8(in, "ar")
- if name != "windows-1256" {
- t.Fatalf("expected windows-1256 for Arabic, got %s", name)
- }
- if string(out) != "ا" {
- t.Fatalf("Arabic decode failed: got %q", out)
- }
-}
diff --git a/internal/library/mediainfo/chromaprint.go b/internal/library/mediainfo/chromaprint.go
deleted file mode 100644
index 14aff34..0000000
--- a/internal/library/mediainfo/chromaprint.go
+++ /dev/null
@@ -1,279 +0,0 @@
-package mediainfo
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "fmt"
- "math/bits"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strconv"
- "strings"
-)
-
-// Chromaprint-based shared-audio detection. Episodes of the same season share
-// an identical intro (OP) and credits (ED) audio track; fingerprinting a window
-// of each episode and finding the longest aligned low-hamming-distance region
-// between two episodes localizes those segments. Clean-room implementation of
-// the approach popularized by Jellyfin's Intro Skipper plugin.
-//
-// Fingerprint stream: chromaprint emits one uint32 per ~0.1238s of audio
-// (11025 Hz mono, FFT 4096, 2/3 overlap → ~8.08 points/second).
-
-const (
- // ChromaprintSampleDur is seconds of audio per fingerprint point.
- ChromaprintSampleDur = 0.1238
- // maxHammingBits: two points are "similar" when their XOR popcount is below this.
- maxHammingBits = 6
- // maxTimeSkipSec: gap tolerance when growing a contiguous similar region.
- maxTimeSkipSec = 3.5
-)
-
-// SkipSegmentRange is one detected skippable range inside a media file.
-type SkipSegmentRange struct {
- Category string `json:"category"` // "intro" | "credits"
- StartSec float64 `json:"startSec"`
- EndSec float64 `json:"endSec"`
-}
-
-// FingerprintAudioWindow decodes [startSec, startSec+lengthSec] of the first
-// audio track with ffmpeg and pipes the WAV into fpcalc -raw, returning the
-// chromaprint point stream.
-func FingerprintAudioWindow(ctx context.Context, ffmpegPath, fpcalcPath, mediaPath string, startSec, lengthSec float64) ([]uint32, error) {
- ff := exec.CommandContext(ctx, ffmpegPath,
- "-nostdin", "-loglevel", "error",
- "-ss", strconv.FormatFloat(startSec, 'f', 3, 64),
- "-i", mediaPath,
- "-t", strconv.FormatFloat(lengthSec, 'f', 3, 64),
- "-map", "0:a:0",
- "-ac", "2",
- "-f", "wav", "-",
- )
- fp := exec.CommandContext(ctx, fpcalcPath,
- "-raw", "-length", strconv.Itoa(int(lengthSec)), "-")
-
- pipe, err := ff.StdoutPipe()
- if err != nil {
- return nil, fmt.Errorf("ffmpeg pipe: %w", err)
- }
- fp.Stdin = pipe
- var ffErr strings.Builder
- ff.Stderr = &ffErr
-
- if err := ff.Start(); err != nil {
- return nil, fmt.Errorf("ffmpeg start: %w", err)
- }
- out, err := fp.Output()
- // fpcalc stops reading once it has processed -length seconds and may exit
- // WITHOUT draining the last buffered bytes. Close our read end so ffmpeg
- // gets EPIPE and exits — otherwise it blocks forever on a full pipe whose
- // only remaining reader is us (caught live: 5-min ctx kills, per file).
- _ = pipe.Close()
- // Always reap ffmpeg; early pipe close makes it exit non-zero — fine as
- // long as fpcalc produced output.
- _ = ff.Wait()
- if err != nil {
- return nil, fmt.Errorf("fpcalc: %w (ffmpeg: %s)", err, strings.TrimSpace(ffErr.String()))
- }
-
- for _, line := range strings.Split(string(out), "\n") {
- if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "FINGERPRINT="); ok {
- parts := strings.Split(rest, ",")
- points := make([]uint32, 0, len(parts))
- for _, p := range parts {
- // fpcalc may print signed ints; parse wide and truncate.
- v, perr := strconv.ParseInt(strings.TrimSpace(p), 10, 64)
- if perr != nil {
- return nil, fmt.Errorf("fpcalc output parse: %w", perr)
- }
- points = append(points, uint32(v))
- }
- if len(points) == 0 {
- return nil, fmt.Errorf("fpcalc produced an empty fingerprint")
- }
- return points, nil
- }
- }
- return nil, fmt.Errorf("no FINGERPRINT line in fpcalc output")
-}
-
-// SharedRegion is the longest aligned similar-audio region between two
-// fingerprint streams, in seconds relative to each stream's start.
-type SharedRegion struct {
- AStart, AEnd float64
- BStart, BEnd float64
- Duration float64
-}
-
-// FindSharedRegion locates the longest contiguous region (bounded by
-// minDur/maxDur seconds) where streams a and b carry near-identical audio at
-// some alignment. Returns nil when no qualifying region exists.
-func FindSharedRegion(a, b []uint32, minDur, maxDur float64) *SharedRegion {
- if len(a) == 0 || len(b) == 0 {
- return nil
- }
- // Inverted index of b: point value → last index seen.
- indexB := make(map[uint32]int, len(b))
- for i, v := range b {
- indexB[v] = i
- }
- // Candidate alignments: exact value matches (±2 on the value tolerates
- // quantization noise between encodes).
- shifts := make(map[int]struct{})
- for i, v := range a {
- for d := -2; d <= 2; d++ {
- if j, ok := indexB[v+uint32(d)]; ok {
- shifts[j-i] = struct{}{}
- }
- }
- }
-
- minPoints := int(minDur / ChromaprintSampleDur)
- gapSec := float64(maxTimeSkipSec)
- gapPoints := int(gapSec / ChromaprintSampleDur)
- var best *SharedRegion
-
- for shift := range shifts {
- i0 := 0
- if shift < 0 {
- i0 = -shift
- }
- i1 := len(a)
- if len(b)-shift < i1 {
- i1 = len(b) - shift
- }
- if i1-i0 < minPoints {
- continue
- }
- runStart, prev := -1, -1
- flush := func(end int) {
- if runStart < 0 {
- return
- }
- dur := float64(end-runStart) * ChromaprintSampleDur
- if dur >= minDur && dur <= maxDur && (best == nil || dur > best.Duration) {
- best = &SharedRegion{
- AStart: float64(runStart) * ChromaprintSampleDur,
- AEnd: float64(end) * ChromaprintSampleDur,
- BStart: float64(runStart+shift) * ChromaprintSampleDur,
- BEnd: float64(end+shift) * ChromaprintSampleDur,
- Duration: dur,
- }
- }
- }
- for i := i0; i < i1; i++ {
- if bits.OnesCount32(a[i]^b[i+shift]) > maxHammingBits {
- continue
- }
- if prev >= 0 && i-prev > gapPoints {
- flush(prev)
- runStart = i
- } else if runStart < 0 {
- runStart = i
- }
- prev = i
- }
- flush(prev)
- }
- return best
-}
-
-// --- Black-frame credits detection (movies: no sibling episode to compare) ---
-
-var blackframeRe = regexp.MustCompile(`frame:\d+\s+pblack:\d+\s+pts:\d+\s+t:([\d.]+)`)
-
-// DetectBlackFrameRuns scans [startSec, startSec+lengthSec] with ffmpeg's
-// blackframe filter and returns the timestamps (absolute seconds) of frames
-// that are ≥minBlackPct black. Used to find the start of end credits in movies
-// (classic credits roll on black).
-func DetectBlackFrameRuns(ctx context.Context, ffmpegPath, mediaPath string, startSec, lengthSec float64, minBlackPct int) ([]float64, error) {
- // Keyframe-only decode: credits-on-black lasts minutes, so sampling one
- // frame every keyframe interval (~2-10s) finds the run at ~2% of the cost
- // of a full decode — the difference between seconds and minutes per 4K film.
- cmd := exec.CommandContext(ctx, ffmpegPath,
- "-nostdin", "-loglevel", "info",
- "-skip_frame", "nokey",
- "-ss", strconv.FormatFloat(startSec, 'f', 3, 64),
- "-i", mediaPath,
- "-t", strconv.FormatFloat(lengthSec, 'f', 3, 64),
- "-an", "-sn",
- "-vf", fmt.Sprintf("blackframe=amount=%d:threshold=32", minBlackPct),
- "-f", "null", "-",
- )
- stderr, err := cmd.StderrPipe()
- if err != nil {
- return nil, err
- }
- if err := cmd.Start(); err != nil {
- return nil, fmt.Errorf("ffmpeg blackframe start: %w", err)
- }
- var times []float64
- sc := bufio.NewScanner(stderr)
- sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
- for sc.Scan() {
- if m := blackframeRe.FindStringSubmatch(sc.Text()); m != nil {
- if t, perr := strconv.ParseFloat(m[1], 64); perr == nil {
- times = append(times, startSec+t)
- }
- }
- }
- if err := cmd.Wait(); err != nil {
- return nil, fmt.Errorf("ffmpeg blackframe: %w", err)
- }
- return times, nil
-}
-
-// --- Sidecar cache for detected segments ---
-
-// skipSegmentsSidecarVersion bumps when the detection algorithm changes enough
-// that cached results should be recomputed.
-const skipSegmentsSidecarVersion = 1
-
-// SkipSegmentsSidecar is the cached detection result for one media file.
-type SkipSegmentsSidecar struct {
- Version int `json:"version"`
- DurationSec float64 `json:"durationSec"`
- Segments []SkipSegmentRange `json:"segments"` // empty = analyzed, nothing found
-}
-
-func skipSegmentsCachePath(mediaPath string) string {
- return filepath.Join(sidecarDir(mediaPath), filepath.Base(mediaPath)+".skipseg.json")
-}
-
-// ReadCachedSkipSegments returns the cached detection result for mediaPath if
-// fresh (newer than the media file) and of the current algorithm version.
-func ReadCachedSkipSegments(mediaPath string) (*SkipSegmentsSidecar, bool) {
- p := skipSegmentsCachePath(mediaPath)
- if !sidecarFresh(p, mediaPath) {
- return nil, false
- }
- data, err := os.ReadFile(p)
- if err != nil {
- return nil, false
- }
- var sc SkipSegmentsSidecar
- if err := json.Unmarshal(data, &sc); err != nil || sc.Version != skipSegmentsSidecarVersion {
- return nil, false
- }
- return &sc, true
-}
-
-// WriteCachedSkipSegments persists a detection result next to the media file.
-func WriteCachedSkipSegments(mediaPath string, durationSec float64, segs []SkipSegmentRange) error {
- if segs == nil {
- segs = []SkipSegmentRange{}
- }
- sc := SkipSegmentsSidecar{Version: skipSegmentsSidecarVersion, DurationSec: durationSec, Segments: segs}
- data, err := json.Marshal(sc)
- if err != nil {
- return err
- }
- dir := sidecarDir(mediaPath)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
- return os.WriteFile(skipSegmentsCachePath(mediaPath), data, 0o644)
-}
diff --git a/internal/library/mediainfo/chromaprint_test.go b/internal/library/mediainfo/chromaprint_test.go
deleted file mode 100644
index 4425893..0000000
--- a/internal/library/mediainfo/chromaprint_test.go
+++ /dev/null
@@ -1,121 +0,0 @@
-package mediainfo
-
-import (
- "math"
- "testing"
-)
-
-// lcg is a tiny deterministic pseudo-random stream for synthetic fingerprints.
-type lcg struct{ state uint64 }
-
-func (l *lcg) next() uint32 {
- l.state = l.state*6364136223846793005 + 1442695040888963407
- return uint32(l.state >> 32)
-}
-
-func TestFindSharedRegion_DetectsAlignedSegment(t *testing.T) {
- // Shared segment: 700 points ≈ 86.7s — a typical anime OP.
- shared := make([]uint32, 700)
- g := &lcg{state: 42}
- for i := range shared {
- shared[i] = g.next()
- }
-
- // a: 80 points of unique noise, then the shared segment, then noise.
- ga := &lcg{state: 1001}
- a := make([]uint32, 0, 2000)
- for i := 0; i < 80; i++ {
- a = append(a, ga.next())
- }
- a = append(a, shared...)
- for len(a) < 2000 {
- a = append(a, ga.next())
- }
-
- // b: 480 points of different noise, then the same shared segment.
- gb := &lcg{state: 2002}
- b := make([]uint32, 0, 2000)
- for i := 0; i < 480; i++ {
- b = append(b, gb.next())
- }
- b = append(b, shared...)
- for len(b) < 2000 {
- b = append(b, gb.next())
- }
-
- r := FindSharedRegion(a, b, 15, 120)
- if r == nil {
- t.Fatal("expected a shared region, got nil")
- }
- wantAStart := 80 * ChromaprintSampleDur
- wantBStart := 480 * ChromaprintSampleDur
- if math.Abs(r.AStart-wantAStart) > 2 {
- t.Errorf("AStart = %.1f, want ≈ %.1f", r.AStart, wantAStart)
- }
- if math.Abs(r.BStart-wantBStart) > 2 {
- t.Errorf("BStart = %.1f, want ≈ %.1f", r.BStart, wantBStart)
- }
- wantDur := 700 * ChromaprintSampleDur
- if math.Abs(r.Duration-wantDur) > 4 {
- t.Errorf("Duration = %.1f, want ≈ %.1f", r.Duration, wantDur)
- }
-}
-
-func TestFindSharedRegion_NoMatchOnNoise(t *testing.T) {
- ga, gb := &lcg{state: 7}, &lcg{state: 9}
- a := make([]uint32, 1500)
- b := make([]uint32, 1500)
- for i := range a {
- a[i] = ga.next()
- b[i] = gb.next()
- }
- if r := FindSharedRegion(a, b, 15, 120); r != nil {
- t.Fatalf("expected nil on unrelated noise, got %+v", r)
- }
-}
-
-func TestFindSharedRegion_FullMatchExceedsMaxDur(t *testing.T) {
- // Two identical streams (same episode, two releases): the only region is
- // the full window, which must be rejected by maxDur.
- g := &lcg{state: 5}
- a := make([]uint32, 2000)
- for i := range a {
- a[i] = g.next()
- }
- b := make([]uint32, 2000)
- copy(b, a)
- if r := FindSharedRegion(a, b, 15, 120); r != nil {
- t.Fatalf("expected nil for identical streams (region > maxDur), got %+v", r)
- }
-}
-
-func TestFindSharedRegion_ToleratesBitNoise(t *testing.T) {
- // Same shared segment but with ≤2 flipped bits per point (re-encode noise).
- shared := make([]uint32, 600)
- g := &lcg{state: 77}
- for i := range shared {
- shared[i] = g.next()
- }
- noisy := make([]uint32, len(shared))
- for i, v := range shared {
- noisy[i] = v ^ (1 << uint(i%20)) // flip one bit
- }
-
- ga, gb := &lcg{state: 100}, &lcg{state: 200}
- a := append(make([]uint32, 0, 1500), shared...)
- for len(a) < 1500 {
- a = append(a, ga.next())
- }
- b := append(make([]uint32, 0, 1500), noisy...)
- for len(b) < 1500 {
- b = append(b, gb.next())
- }
-
- r := FindSharedRegion(a, b, 15, 120)
- if r == nil {
- t.Fatal("expected match despite 1-bit noise, got nil")
- }
- if r.AStart > 2 {
- t.Errorf("AStart = %.1f, want ≈ 0", r.AStart)
- }
-}
diff --git a/internal/library/mediainfo/ffprobe.go b/internal/library/mediainfo/ffprobe.go
index 3f1628f..5b33979 100644
--- a/internal/library/mediainfo/ffprobe.go
+++ b/internal/library/mediainfo/ffprobe.go
@@ -67,14 +67,8 @@ func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*Media
output, err := cmd.Output()
if err != nil {
- // A remote URL (debrid HLS-from-URL, hueco #2/2b) has no local file to
- // stat — surface ffprobe's own stderr (e.g. "Protocol not found" when the
- // ffmpeg build lacks TLS, or an HTTP error) instead of a misleading
- // "file not found". Only treat a genuine local path as possibly-missing.
- if !strings.Contains(filePath, "://") {
- if _, statErr := os.Stat(filePath); statErr != nil {
- return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
- }
+ if _, statErr := os.Stat(filePath); statErr != nil {
+ return nil, fmt.Errorf("ffprobe: file not found: %s", filePath)
}
return nil, fmt.Errorf("ffprobe failed (file=%s): %s", filePath, stderr.String())
}
@@ -84,65 +78,7 @@ func ExtractMediaInfo(ctx context.Context, ffprobePath, filePath string) (*Media
return nil, fmt.Errorf("ffprobe JSON parse failed: %w", err)
}
- mi, perr := parseFFprobeOutput(data)
- if perr != nil {
- return nil, perr
- }
- // A corrupt-but-parseable file (e.g. a half-downloaded MKV) returns valid
- // stream JSON and a zero exit, yet ffprobe still logs structural errors to
- // stderr (captured above). Flag it so the library can warn instead of
- // silently shipping a file that won't play.
- if integ := assessIntegrity(stderr.String(), mi); integ != nil {
- mi.Integrity = integ
- }
- // Append external sidecar subtitles (a .srt/.ass next to the video, or a
- // Subs/ bundle) AFTER the embedded streams, so embedded keep slice positions
- // == their 0:s:N index. Local files only — a remote URL has no directory to
- // scan (debrid streams rely on embedded subs from the URL). Best-effort:
- // DiscoverSidecarSubtitles returns nil on an unreadable dir.
- if !strings.Contains(filePath, "://") {
- if ext := DiscoverSidecarSubtitles(filePath); len(ext) > 0 {
- mi.Subtitles = append(mi.Subtitles, ext...)
- }
- }
- return mi, nil
-}
-
-// corruptionMarkers are high-confidence ffprobe stderr substrings (lowercased)
-// that indicate a structurally damaged / incompletely-downloaded file, paired
-// with a STABLE code the web maps to localized copy. Kept conservative so
-// healthy files are never flagged — each appears only on real container/
-// bitstream damage, not benign warnings (ffprobe runs at -v error).
-var corruptionMarkers = []struct{ sub, code string }{
- {"invalid data found when processing input", "invalid_data"},
- {"as first byte of an ebml number", "ebml_corrupt"}, // truncated/corrupt MKV
- {"moov atom not found", "moov_missing"}, // truncated MP4
- {"invalid nal unit size", "bitstream_corrupt"},
- {"non-existing pps", "bitstream_corrupt"},
- // NOTE: deliberately NOT matching "error reading header" (ffprobe emits it
- // on transient NFS/network read hiccups — a genuinely unreadable header
- // also exits non-zero → ScanError → item skipped) nor "truncating packet"
- // (printed for healthy MKV/TS with oversized subtitle/PGS packets). Both
- // false-positive on good files; the markers above are structural.
-}
-
-// assessIntegrity inspects ffprobe's stderr plus the parsed result and returns
-// a damaged verdict on a high-confidence corruption signal, else nil. The
-// Reason is a stable code (see corruptionMarkers) the web localizes.
-func assessIntegrity(stderr string, mi *MediaInfo) *IntegrityInfo {
- low := strings.ToLower(stderr)
- for _, m := range corruptionMarkers {
- if strings.Contains(low, m.sub) {
- return &IntegrityInfo{Damaged: true, Reason: m.code}
- }
- }
- // A file that carries a video stream but no determinable duration is almost
- // always truncated (the moov/cues holding duration sit at the end of the
- // file). Audio-only items legitimately omit it, so gate on having video.
- if mi != nil && mi.Video != nil && mi.Video.Duration <= 0 {
- return &IntegrityInfo{Damaged: true, Reason: "no_duration"}
- }
- return nil
+ return parseFFprobeOutput(data)
}
// parseFFprobeOutput converts parsed ffprobe JSON into MediaInfo.
diff --git a/internal/library/mediainfo/ffprobe_test.go b/internal/library/mediainfo/ffprobe_test.go
index e7e2a43..e29eed1 100644
--- a/internal/library/mediainfo/ffprobe_test.go
+++ b/internal/library/mediainfo/ffprobe_test.go
@@ -428,42 +428,3 @@ func TestParseFFprobeOutput_FrameRateNoSlash(t *testing.T) {
t.Errorf("frameRate = %v, want 0 (no slash)", mi.Video.FrameRate)
}
}
-
-func TestAssessIntegrity(t *testing.T) {
- healthy := &MediaInfo{Video: &VideoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 5477}}
-
- // Healthy file with no stderr → nil (not damaged).
- if got := assessIntegrity("", healthy); got != nil {
- t.Errorf("healthy file flagged damaged: %+v", got)
- }
-
- // MKV EBML corruption (the real "In the Grey" case): ffprobe exits 0 but
- // logs EBML errors → damaged with the ebml_corrupt code.
- ebml := "[matroska,webm @ 0x60e7] 0x00 at pos 2144995 invalid as first byte of an EBML number\n"
- got := assessIntegrity(ebml, healthy)
- if got == nil || !got.Damaged || got.Reason != "ebml_corrupt" {
- t.Errorf("EBML corruption not flagged correctly: %+v", got)
- }
-
- // Truncated MP4.
- if got := assessIntegrity("moov atom not found\n", healthy); got == nil || got.Reason != "moov_missing" {
- t.Errorf("moov-missing not flagged: %+v", got)
- }
-
- // Invalid data.
- if got := assessIntegrity("Invalid data found when processing input\n", healthy); got == nil || got.Reason != "invalid_data" {
- t.Errorf("invalid-data not flagged: %+v", got)
- }
-
- // No duration on a video stream → truncated.
- noDur := &MediaInfo{Video: &VideoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 0}}
- if got := assessIntegrity("", noDur); got == nil || got.Reason != "no_duration" {
- t.Errorf("no-duration not flagged: %+v", got)
- }
-
- // Audio-only file with no duration is NOT flagged (legitimately omits it).
- audioOnly := &MediaInfo{Audio: []AudioTrack{{Lang: "en", Codec: "aac"}}}
- if got := assessIntegrity("", audioOnly); got != nil {
- t.Errorf("audio-only file wrongly flagged: %+v", got)
- }
-}
diff --git a/internal/library/mediainfo/fpcalc.go b/internal/library/mediainfo/fpcalc.go
deleted file mode 100644
index 040bc88..0000000
--- a/internal/library/mediainfo/fpcalc.go
+++ /dev/null
@@ -1,148 +0,0 @@
-package mediainfo
-
-import (
- "archive/tar"
- "compress/gzip"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strings"
- "time"
-)
-
-// fpcalc (chromaprint) powers skip-segment detection: the ffmpeg static builds
-// we download from ffbinaries do NOT include the chromaprint muxer, so audio
-// fingerprinting pipes decoded WAV from our ffmpeg into a standalone fpcalc
-// binary. acoustid publishes small (~2MB) static builds per platform.
-
-const fpcalcVersion = "1.6.0"
-
-var fpcalcDLClient = &http.Client{Timeout: 5 * time.Minute}
-
-const maxFpcalcArchiveSize = 50 * 1024 * 1024 // 50MB
-
-// fpcalcDownloadURL returns the release asset URL for the current platform,
-// and whether the asset is a zip (Windows) instead of tar.gz.
-func fpcalcDownloadURL() (url string, isZip bool, err error) {
- base := fmt.Sprintf("https://github.com/acoustid/chromaprint/releases/download/v%s/chromaprint-fpcalc-%s-", fpcalcVersion, fpcalcVersion)
- switch runtime.GOOS {
- case "linux":
- switch runtime.GOARCH {
- case "amd64":
- return base + "linux-x86_64.tar.gz", false, nil
- case "arm64":
- return base + "linux-arm64.tar.gz", false, nil
- }
- case "darwin":
- return base + "macos-universal.tar.gz", false, nil
- case "windows":
- if runtime.GOARCH == "amd64" {
- return base + "windows-x86_64.zip", true, nil
- }
- }
- return "", false, fmt.Errorf("no fpcalc build for platform %s/%s", runtime.GOOS, runtime.GOARCH)
-}
-
-// FpcalcCachePath returns the cached fpcalc binary path (same bin dir as the
-// downloaded ffmpeg/ffprobe).
-func FpcalcCachePath() (string, error) {
- dir, err := FFprobeCacheDir()
- if err != nil {
- return "", err
- }
- name := "fpcalc"
- if runtime.GOOS == "windows" {
- name = "fpcalc.exe"
- }
- return filepath.Join(dir, name), nil
-}
-
-// ResolveFpcalc finds a usable fpcalc binary: PATH → cache dir → download.
-func ResolveFpcalc() (string, error) {
- if p, err := exec.LookPath("fpcalc"); err == nil {
- return p, nil
- }
- dest, err := FpcalcCachePath()
- if err != nil {
- return "", err
- }
- if _, err := os.Stat(dest); err == nil {
- return dest, nil
- }
- return downloadFpcalc(dest)
-}
-
-func downloadFpcalc(dest string) (string, error) {
- url, isZip, err := fpcalcDownloadURL()
- if err != nil {
- return "", err
- }
-
- fmt.Fprintf(os.Stderr, "fpcalc not found — downloading chromaprint %s...\n", fpcalcVersion)
-
- resp, err := fpcalcDLClient.Get(url)
- if err != nil {
- return "", fmt.Errorf("fpcalc download failed: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return "", fmt.Errorf("fpcalc download failed: HTTP %d", resp.StatusCode)
- }
-
- data, err := io.ReadAll(io.LimitReader(resp.Body, maxFpcalcArchiveSize))
- if err != nil {
- return "", fmt.Errorf("fpcalc download read failed: %w", err)
- }
-
- name := "fpcalc"
- if runtime.GOOS == "windows" {
- name = "fpcalc.exe"
- }
-
- var binary []byte
- if isZip {
- binary, err = extractFromZip(data, name)
- } else {
- binary, err = extractFromTarGz(data, name)
- }
- if err != nil {
- return "", err
- }
-
- if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
- return "", fmt.Errorf("cannot create cache directory: %w", err)
- }
- if err := os.WriteFile(dest, binary, 0o755); err != nil {
- return "", fmt.Errorf("cannot write fpcalc binary: %w", err)
- }
-
- fmt.Fprintf(os.Stderr, "fpcalc installed to %s\n", dest)
- return dest, nil
-}
-
-func extractFromTarGz(data []byte, target string) ([]byte, error) {
- gz, err := gzip.NewReader(strings.NewReader(string(data)))
- if err != nil {
- return nil, fmt.Errorf("cannot open downloaded archive: %w", err)
- }
- defer gz.Close()
-
- tr := tar.NewReader(gz)
- for {
- hdr, err := tr.Next()
- if err == io.EOF {
- break
- }
- if err != nil {
- return nil, fmt.Errorf("cannot read archive: %w", err)
- }
- if hdr.Typeflag == tar.TypeReg && filepath.Base(hdr.Name) == target {
- return io.ReadAll(io.LimitReader(tr, maxFpcalcArchiveSize))
- }
- }
- return nil, fmt.Errorf("%s not found in downloaded archive", target)
-}
diff --git a/internal/library/mediainfo/gallery_real_test.go b/internal/library/mediainfo/gallery_real_test.go
deleted file mode 100644
index 4aa9280..0000000
--- a/internal/library/mediainfo/gallery_real_test.go
+++ /dev/null
@@ -1,206 +0,0 @@
-package mediainfo
-
-import (
- "context"
- "os"
- "path/filepath"
- "sort"
- "strings"
- "testing"
- "time"
-)
-
-// TestGalleryReal is a manual end-to-end harness against a REAL media library.
-// It is skipped unless GALLERY_DIR is set, so it never runs in CI.
-//
-// GALLERY_DIR=/mnt/nas/peliculas go test ./internal/library/mediainfo/ \
-// -run TestGalleryReal -v -timeout 30m
-//
-// It surveys every video file (embedded subs via ffprobe + discovered sidecars),
-// then actually extracts WebVTT for one representative of each kind and checks the
-// output is a valid, non-empty WEBVTT document.
-func TestGalleryReal(t *testing.T) {
- dir := os.Getenv("GALLERY_DIR")
- if dir == "" {
- t.Skip("set GALLERY_DIR to run the real-gallery survey")
- }
- ffprobe := envOr("FFPROBE", "ffprobe")
- ffmpeg := envOr("FFMPEG", "ffmpeg")
-
- videoExt := map[string]bool{".mkv": true, ".mp4": true, ".avi": true, ".m4v": true, ".webm": true, ".mov": true, ".ts": true}
- var videos []string
- _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() {
- return nil
- }
- if strings.Contains(p, "/.unarr/") || strings.Contains(p, "/.Trash") || strings.Contains(p, "/@eaDir/") {
- return nil
- }
- if videoExt[strings.ToLower(filepath.Ext(p))] {
- videos = append(videos, p)
- }
- return nil
- })
- sort.Strings(videos)
- t.Logf("found %d video files under %s", len(videos), dir)
-
- type cat struct {
- embTextCodecs map[string]int // codec → count of files
- embBitmap map[string]int
- extCodecs map[string]int
- filesEmbText []string
- filesEmbBitmap []string
- filesExt []string
- errs int
- }
- c := cat{embTextCodecs: map[string]int{}, embBitmap: map[string]int{}, extCodecs: map[string]int{}}
-
- for _, v := range videos {
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
- mi, err := ExtractMediaInfo(ctx, ffprobe, v)
- cancel()
- if err != nil {
- c.errs++
- t.Logf("PROBE ERR %s: %v", filepath.Base(v), err)
- continue
- }
- var sawEmbText, sawEmbBitmap, sawExt bool
- for _, s := range mi.Subtitles {
- codec := strings.ToLower(s.Codec)
- switch {
- case s.External:
- c.extCodecs[codec]++
- sawExt = true
- case IsTextSubtitleCodec(codec):
- c.embTextCodecs[codec]++
- sawEmbText = true
- default:
- c.embBitmap[codec]++
- sawEmbBitmap = true
- }
- }
- if sawEmbText {
- c.filesEmbText = append(c.filesEmbText, v)
- }
- if sawEmbBitmap {
- c.filesEmbBitmap = append(c.filesEmbBitmap, v)
- }
- if sawExt {
- c.filesExt = append(c.filesExt, v)
- }
- }
-
- t.Logf("=== CENSUS ===")
- t.Logf("probe errors: %d", c.errs)
- t.Logf("embedded TEXT codecs (files w/ track): %v", c.embTextCodecs)
- t.Logf("embedded BITMAP codecs (burn-in only): %v", c.embBitmap)
- t.Logf("external SIDECAR codecs: %v", c.extCodecs)
- t.Logf("files w/ embedded text: %d | w/ embedded bitmap: %d | w/ external sidecar: %d",
- len(c.filesEmbText), len(c.filesEmbBitmap), len(c.filesExt))
-
- // --- Real extraction checks ---
- validVTT := func(b []byte) bool {
- return len(b) > 0 && strings.HasPrefix(strings.TrimSpace(string(b)), "WEBVTT")
- }
-
- // Embedded text: extract index 0 of the first such file.
- if len(c.filesEmbText) > 0 {
- f := c.filesEmbText[0]
- ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
- out, err := ExtractSubtitleVTT(ctx, ffmpeg, f, 0)
- cancel()
- if err != nil || !validVTT(out) {
- t.Errorf("EMBEDDED extract FAILED for %s: err=%v len=%d", filepath.Base(f), err, len(out))
- } else {
- t.Logf("EMBEDDED extract OK: %s → %d bytes WebVTT", filepath.Base(f), len(out))
- }
- }
-
- // External sidecar: find one and extract it via the path-addressed function.
- if len(c.filesExt) > 0 {
- f := c.filesExt[0]
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
- mi, _ := ExtractMediaInfo(ctx, ffprobe, f)
- cancel()
- var subPath, lang string
- for _, s := range mi.Subtitles {
- if s.External {
- subPath, lang = s.Path, s.Lang
- break
- }
- }
- ctx2, cancel2 := context.WithTimeout(context.Background(), 60*time.Second)
- out, err := ExtractExternalSubtitleVTT(ctx2, ffmpeg, subPath, lang)
- cancel2()
- if err != nil || !validVTT(out) {
- t.Errorf("EXTERNAL extract FAILED for %s: err=%v len=%d", filepath.Base(subPath), err, len(out))
- } else {
- t.Logf("EXTERNAL extract OK: %s (lang=%s) → %d bytes WebVTT", filepath.Base(subPath), lang, len(out))
- }
- }
-}
-
-func envOr(k, def string) string {
- if v := os.Getenv(k); v != "" {
- return v
- }
- return def
-}
-
-// TestGalleryExtractAllSidecars extracts EVERY discovered sidecar in the gallery
-// and reports any that fail — the real proof the external path is robust across
-// formats/charsets. Skipped unless GALLERY_DIR is set.
-func TestGalleryExtractAllSidecars(t *testing.T) {
- dir := os.Getenv("GALLERY_DIR")
- if dir == "" {
- t.Skip("set GALLERY_DIR")
- }
- ffmpeg := envOr("FFMPEG", "ffmpeg")
- var subs []SubtitleTrack
- _ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
- if err != nil || d.IsDir() || strings.Contains(p, "/.unarr/") || strings.Contains(p, "/.Trash") || strings.Contains(p, "/@eaDir/") {
- return nil
- }
- ext := strings.ToLower(filepath.Ext(p))
- if videoOf(ext) {
- subs = append(subs, DiscoverSidecarSubtitles(p)...)
- }
- return nil
- })
- // Dedupe by path.
- seen := map[string]bool{}
- var uniq []SubtitleTrack
- for _, s := range subs {
- if !seen[s.Path] {
- seen[s.Path] = true
- uniq = append(uniq, s)
- }
- }
- t.Logf("discovered %d unique sidecar subtitle files", len(uniq))
- fails := 0
- for _, s := range uniq {
- ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
- out, err := ExtractExternalSubtitleVTT(ctx, ffmpeg, s.Path, s.Lang)
- cancel()
- ok := len(out) > 0 && strings.HasPrefix(strings.TrimSpace(string(out)), "WEBVTT")
- if err != nil || !ok {
- fails++
- t.Errorf("FAIL %s (lang=%s codec=%s): err=%v len=%d", filepath.Base(s.Path), s.Lang, s.Codec, err, len(out))
- } else {
- t.Logf("OK %s (lang=%s codec=%s) → %d bytes", filepath.Base(s.Path), s.Lang, s.Codec, len(out))
- }
- }
- if fails > 0 {
- t.Errorf("%d/%d sidecar extractions failed", fails, len(uniq))
- } else {
- t.Logf("all %d sidecar extractions produced valid WebVTT", len(uniq))
- }
-}
-
-func videoOf(ext string) bool {
- switch ext {
- case ".mkv", ".mp4", ".avi", ".m4v", ".webm", ".mov", ".ts":
- return true
- }
- return false
-}
diff --git a/internal/library/mediainfo/harden_linux_test.go b/internal/library/mediainfo/harden_linux_test.go
deleted file mode 100644
index 910add5..0000000
--- a/internal/library/mediainfo/harden_linux_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-//go:build linux
-
-package mediainfo
-
-import (
- "fmt"
- "os"
- "os/exec"
- "strconv"
- "strings"
- "syscall"
- "testing"
- "time"
-)
-
-// TestHardenCmd_KillsChildOnParentDeath is the e2e guarantee for the orphan fix:
-// a child spawned with hardenCmd must be SIGKILL'd by the kernel the instant its
-// parent process dies (Pdeathsig), so an agent crash/restart can never leave an
-// ffmpeg running to ppid 1. It re-execs this test binary as a short-lived helper
-// that starts `sleep`, prints the sleep PID, then exits — and asserts that PID is
-// gone afterwards.
-func TestHardenCmd_KillsChildOnParentDeath(t *testing.T) {
- if os.Getenv("UNARR_PDEATHSIG_CHILD") == "1" {
- // Helper role: start a hardened long sleep, announce its PID, then exit so
- // the kernel fires Pdeathsig on it.
- cmd := exec.Command("sleep", "120")
- hardenCmd(cmd)
- if err := cmd.Start(); err != nil {
- fmt.Println("ERR", err)
- os.Exit(2)
- }
- fmt.Println(cmd.Process.Pid)
- os.Exit(0)
- }
-
- helper := exec.Command(os.Args[0], "-test.run=TestHardenCmd_KillsChildOnParentDeath", "-test.v")
- helper.Env = append(os.Environ(), "UNARR_PDEATHSIG_CHILD=1")
- out, err := helper.Output()
- if err != nil {
- t.Fatalf("helper run: %v (out=%q)", err, out)
- }
-
- var sleepPID int
- for _, line := range strings.Split(string(out), "\n") {
- if n, perr := strconv.Atoi(strings.TrimSpace(line)); perr == nil && n > 0 {
- sleepPID = n
- break
- }
- }
- if sleepPID == 0 {
- t.Fatalf("could not parse child PID from helper output: %q", out)
- }
-
- // Give the kernel a moment to deliver SIGKILL after the helper exited.
- deadline := time.Now().Add(3 * time.Second)
- for time.Now().Before(deadline) {
- if syscall.Kill(sleepPID, 0) != nil {
- return // process gone → Pdeathsig worked
- }
- time.Sleep(50 * time.Millisecond)
- }
- // Cleanup if it somehow survived, then fail.
- _ = syscall.Kill(sleepPID, syscall.SIGKILL)
- t.Fatalf("child %d survived parent death — Pdeathsig not applied (orphan leak)", sleepPID)
-}
diff --git a/internal/library/mediainfo/ioprio_linux.go b/internal/library/mediainfo/ioprio_linux.go
deleted file mode 100644
index 210e922..0000000
--- a/internal/library/mediainfo/ioprio_linux.go
+++ /dev/null
@@ -1,75 +0,0 @@
-//go:build linux
-
-package mediainfo
-
-import (
- "os"
- "os/exec"
- "strconv"
- "strings"
- "syscall"
-)
-
-// Linux I/O priority (ioprio) constants. The 16-bit ioprio value packs a class
-// in the top 3 bits (shift 13) and a class-data nibble below it; the IDLE class
-// takes no data.
-const (
- ioprioWhoProcess = 1 // IOPRIO_WHO_PROCESS
- ioprioClassIdle = 3 // IOPRIO_CLASS_IDLE
- ioprioClassShift = 13
-)
-
-// setIdleIOPriority best-effort lowers a process's I/O scheduling class to IDLE,
-// so a long background read (the subtitle prewarm of a huge remux — a single
-// ~14 min sequential read of a 60GB file over NFS) yields disk/NFS bandwidth to
-// foreground work like live streaming. Linux-only; on kernels or filesystems
-// that don't honor ioprio this simply has no effect. Errors are intentionally
-// ignored — it's an optimization, never required for correctness.
-func setIdleIOPriority(pid int) {
- ioprio := ioprioClassIdle << ioprioClassShift // IDLE class, data 0
- _, _, _ = syscall.Syscall(syscall.SYS_IOPRIO_SET, uintptr(ioprioWhoProcess), uintptr(pid), uintptr(ioprio))
-}
-
-// setLowCPUPriority best-effort drops a process to the lowest CPU niceness (19),
-// so the heavy trickplay full-decode pass yields the CPU to foreground work.
-// Pairs with setIdleIOPriority (disk): IDLE I/O alone is not enough when the
-// bottleneck is software/contended 4K decode — without CPU nice, N stacked
-// decodes pin every core (the host hit load ~140). Errors are ignored — it's an
-// optimization, not required for correctness.
-func setLowCPUPriority(pid int) {
- _ = syscall.Setpriority(syscall.PRIO_PROCESS, pid, 19)
-}
-
-// hardenCmd makes the child ffmpeg die with this agent. Setpgid isolates it in
-// its own process group, and Pdeathsig=SIGKILL asks the kernel to kill it the
-// instant the agent process dies. Without this, exec.CommandContext can only
-// enforce its timeout from an in-process goroutine — an agent crash / restart /
-// SIGKILL kills that goroutine, so the ffmpeg is reparented to init (ppid 1) and
-// runs its full 45-min decode to the end. Successive dev restarts stacked those
-// orphans (one pair per restart) and spiked the box to load ~140.
-func hardenCmd(cmd *exec.Cmd) {
- if cmd.SysProcAttr == nil {
- cmd.SysProcAttr = &syscall.SysProcAttr{}
- }
- cmd.SysProcAttr.Setpgid = true
- cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
-}
-
-// LoadAverage1 returns the 1-minute system load from /proc/loadavg. ok=false when
-// it can't be read, so callers treat "unknown" as "don't gate" (proceed) rather
-// than blocking forever.
-func LoadAverage1() (float64, bool) {
- b, err := os.ReadFile("/proc/loadavg")
- if err != nil {
- return 0, false
- }
- fields := strings.Fields(string(b))
- if len(fields) == 0 {
- return 0, false
- }
- v, err := strconv.ParseFloat(fields[0], 64)
- if err != nil {
- return 0, false
- }
- return v, true
-}
diff --git a/internal/library/mediainfo/ioprio_other.go b/internal/library/mediainfo/ioprio_other.go
deleted file mode 100644
index cea220a..0000000
--- a/internal/library/mediainfo/ioprio_other.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build !linux
-
-package mediainfo
-
-import "os/exec"
-
-// These are Linux-specific optimizations / safeguards; no-ops elsewhere.
-func setIdleIOPriority(_ int) {}
-func setLowCPUPriority(_ int) {}
-func hardenCmd(_ *exec.Cmd) {}
-
-// LoadAverage1 is unavailable off Linux; ok=false means callers don't gate.
-func LoadAverage1() (float64, bool) { return 0, false }
diff --git a/internal/library/mediainfo/lang.go b/internal/library/mediainfo/lang.go
index 10acae8..0a5d42f 100644
--- a/internal/library/mediainfo/lang.go
+++ b/internal/library/mediainfo/lang.go
@@ -64,13 +64,7 @@ var langNormalize = map[string]string{
"mlt": "mt", "mt": "mt",
"swa": "sw", "sw": "sw",
"afr": "af", "af": "af",
- "kan": "kn", "kn": "kn",
- "mal": "ml", "ml": "ml",
- "mar": "mr", "mr": "mr",
- "pan": "pa", "pa": "pa",
- "guj": "gu", "gu": "gu",
- "kann": "kn",
- "lat": "la", "la": "la",
+ "lat": "la", "la": "la",
// Full English names (ffprobe sometimes returns these instead of codes)
"english": "en", "spanish": "es", "french": "fr", "german": "de",
diff --git a/internal/library/mediainfo/sidecar.go b/internal/library/mediainfo/sidecar.go
deleted file mode 100644
index de16c57..0000000
--- a/internal/library/mediainfo/sidecar.go
+++ /dev/null
@@ -1,373 +0,0 @@
-package mediainfo
-
-import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "log"
- "math"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
-)
-
-// Sidecar cache: unarr stores extracted artifacts (WebVTT subtitles, thumbnail
-// frames) in a hidden ".unarr" directory NEXT TO the media file, not in the XDG
-// cache. Keeping them beside the content means they travel with the file and
-// survive a cache-dir wipe, and the scan-time prewarm and the on-demand stream
-// handlers share the exact same path scheme — so a subtitle/thumbnail extracted
-// during a library scan is reused verbatim at play time (no re-extraction, no
-// 60s-HTTP-timeout failures on huge remuxes).
-//
-// Everything here is best-effort: a read-only media mount just means no cache
-// (the on-demand path still works), and a stale cache (media replaced) is
-// detected by mtime and ignored.
-
-const sidecarDirName = ".unarr"
-
-// IsTextSubtitleCodec reports whether a subtitle codec can be extracted to
-// WebVTT (text-based). Mirrors engine.ProbeSubtitleTrack.IsTextSubtitle and the
-// web's isTextSubtitleCodec whitelist — bitmap subs (PGS/DVB/VOBSUB) are burned
-// in, not extracted. Defined here (the leaf media package) so both the stream
-// handlers and the scan-time prewarm classify codecs identically.
-func IsTextSubtitleCodec(codec string) bool {
- switch strings.ToLower(strings.TrimSpace(codec)) {
- case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text":
- return true
- default:
- return false
- }
-}
-
-// sidecarDir returns the hidden per-folder cache directory for a media file.
-func sidecarDir(mediaPath string) string {
- return filepath.Join(filepath.Dir(mediaPath), sidecarDirName)
-}
-
-// subtitleCachePath is the cached WebVTT path for subtitle stream `index`
-// (0-based, matching ffmpeg's 0:s:N ordering) of mediaPath.
-func subtitleCachePath(mediaPath string, index int) string {
- return filepath.Join(sidecarDir(mediaPath), fmt.Sprintf("%s.s%d.vtt", filepath.Base(mediaPath), index))
-}
-
-// thumbnailCachePath is the cached JPEG path for a single frame at posSec
-// (rounded to whole seconds) and the given width. The handler and the scan
-// prewarm round identically so the same logical frame maps to one cache file.
-func thumbnailCachePath(mediaPath string, posSec float64, width int) string {
- sec := int(math.Round(posSec))
- if sec < 0 {
- sec = 0
- }
- return filepath.Join(sidecarDir(mediaPath), fmt.Sprintf("%s.t%dw%d.jpg", filepath.Base(mediaPath), sec, width))
-}
-
-// sidecarFresh reports whether a cache file exists and is at least as new as the
-// media file. A re-download/replace bumps the media mtime and invalidates the
-// stale sidecar so we re-extract.
-func sidecarFresh(cachePath, mediaPath string) bool {
- cfi, err := os.Stat(cachePath)
- if err != nil {
- return false
- }
- mfi, err := os.Stat(mediaPath)
- if err != nil {
- return false
- }
- return !cfi.ModTime().Before(mfi.ModTime())
-}
-
-// writeSidecar atomically writes data to a sidecar path (temp + rename), creating
-// the hidden dir if needed. Returns an error the caller logs and continues on
-// (e.g. a read-only mount) — caching is never required for correctness.
-func writeSidecar(path string, data []byte) error {
- if len(data) == 0 {
- return errors.New("refusing to cache empty artifact")
- }
- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
- return err
- }
- tmp := path + ".tmp"
- if err := os.WriteFile(tmp, data, 0o644); err != nil {
- return err
- }
- if err := os.Rename(tmp, path); err != nil {
- _ = os.Remove(tmp)
- return err
- }
- return nil
-}
-
-// ReadCachedSubtitle returns the cached WebVTT for (mediaPath, index) when a
-// fresh sidecar exists. ok=false means the caller should extract on demand.
-func ReadCachedSubtitle(mediaPath string, index int) ([]byte, bool) {
- p := subtitleCachePath(mediaPath, index)
- if !sidecarFresh(p, mediaPath) {
- return nil, false
- }
- b, err := os.ReadFile(p)
- if err != nil || len(b) == 0 {
- return nil, false
- }
- return b, true
-}
-
-// WriteCachedSubtitle stores extracted WebVTT next to the media. Best-effort.
-func WriteCachedSubtitle(mediaPath string, index int, vtt []byte) error {
- return writeSidecar(subtitleCachePath(mediaPath, index), vtt)
-}
-
-// ExtractSubtitleVTT runs ffmpeg to convert subtitle stream `index` of mediaPath
-// to WebVTT bytes. Shared by the on-demand /sub handler and the scan-time prewarm
-// so both produce identical output. The caller owns the ctx deadline: the handler
-// uses a short HTTP-bound timeout; the prewarm uses a generous one (a full text
-// track on a multi-GB remux can take minutes to demux).
-func ExtractSubtitleVTT(ctx context.Context, ffmpegPath, mediaPath string, index int) ([]byte, error) {
- // -map 0:s:? selects the Nth subtitle stream (non-fatal if absent);
- // -c:s webvtt converts srt/ass/mov_text/etc. to WebVTT on stdout.
- args := []string{
- "-nostdin",
- "-loglevel", "error",
- "-i", mediaPath,
- "-map", fmt.Sprintf("0:s:%d?", index),
- "-c:s", "webvtt",
- "-f", "webvtt",
- "-",
- }
- cmd := exec.CommandContext(ctx, ffmpegPath, args...)
- var stderr strings.Builder
- cmd.Stderr = &stderr
- out, err := cmd.Output()
- if err != nil {
- return nil, fmt.Errorf("ffmpeg subtitle extract: %w: %s", err, strings.TrimSpace(stderr.String()))
- }
- if len(out) == 0 {
- return nil, errors.New("ffmpeg produced no subtitle output")
- }
- return out, nil
-}
-
-// ExtractExternalSubtitleVTT converts a STANDALONE sidecar subtitle file (a
-// .srt/.ass/.ssa/.vtt sitting next to the media) to WebVTT. Unlike the embedded
-// path it has no stream index — the whole file is the track. It first transcodes
-// the bytes to UTF-8 (legacy code pages → mojibake otherwise; see charset.go)
-// using the track's language as the detection hint, then runs ffmpeg to emit
-// WebVTT. The UTF-8 bytes go through a temp file with the ORIGINAL extension so
-// ffmpeg selects the right demuxer (.srt→subrip, .ass→ass, .vtt→webvtt), and
-// `-sub_charenc UTF-8` stops ffmpeg from re-guessing what we already decoded.
-func ExtractExternalSubtitleVTT(ctx context.Context, ffmpegPath, subPath, langHint string) ([]byte, error) {
- raw, err := os.ReadFile(subPath)
- if err != nil {
- return nil, fmt.Errorf("read sidecar subtitle: %w", err)
- }
- if len(raw) == 0 {
- return nil, errors.New("sidecar subtitle is empty")
- }
- utf8Bytes, encName := DecodeSubtitleToUTF8(raw, langHint)
- // A "(raw)" suffix means the legacy transcode failed and we're passing the
- // original bytes through — the likeliest cause of user-visible mojibake, so
- // leave a trail to diagnose it in the field.
- if strings.HasSuffix(encName, "(raw)") {
- log.Printf("[sub] external charset transcode fell back to raw bytes (%s, lang=%q): possible mojibake", filepath.Base(subPath), langHint)
- }
-
- ext := strings.ToLower(filepath.Ext(subPath))
- if ext == "" {
- ext = ".srt"
- }
- tmpDir, err := os.MkdirTemp("", "unarr-extsub-")
- if err != nil {
- return nil, err
- }
- defer func() { _ = os.RemoveAll(tmpDir) }()
- tmpIn := filepath.Join(tmpDir, "in"+ext)
- if werr := os.WriteFile(tmpIn, utf8Bytes, 0o600); werr != nil {
- return nil, werr
- }
-
- args := []string{
- "-nostdin",
- "-loglevel", "error",
- "-sub_charenc", "UTF-8",
- "-i", tmpIn,
- "-c:s", "webvtt",
- "-f", "webvtt",
- "-",
- }
- cmd := exec.CommandContext(ctx, ffmpegPath, args...)
- var stderr strings.Builder
- cmd.Stderr = &stderr
- out, err := cmd.Output()
- if err != nil {
- return nil, fmt.Errorf("ffmpeg external subtitle extract: %w: %s", err, strings.TrimSpace(stderr.String()))
- }
- if len(out) == 0 {
- return nil, errors.New("ffmpeg produced no subtitle output")
- }
- return out, nil
-}
-
-// ExtractSubtitlesVTTMulti extracts several text subtitle streams in a SINGLE
-// ffmpeg pass. The expensive part of subtitle extraction is demuxing the whole
-// container (subtitle packets are interleaved across the runtime), so a 60GB
-// remux with N text tracks costs N full reads when done one index at a time —
-// here it's one read for all of them. Returns index→WebVTT for the streams that
-// produced output (an empty stream is simply absent, not an error). ffmpeg can't
-// multiplex several outputs onto stdout, so it writes per-track temp files which
-// are read back; callers cache them via WriteCachedSubtitle.
-func ExtractSubtitlesVTTMulti(ctx context.Context, ffmpegPath, mediaPath string, indices []int) (map[int][]byte, error) {
- if len(indices) == 0 {
- return nil, nil
- }
- tmpDir, err := os.MkdirTemp("", "unarr-subs-")
- if err != nil {
- return nil, err
- }
- defer func() { _ = os.RemoveAll(tmpDir) }()
-
- args := []string{"-nostdin", "-loglevel", "error", "-i", mediaPath}
- tmp := make(map[int]string, len(indices))
- for _, idx := range indices {
- f := filepath.Join(tmpDir, fmt.Sprintf("s%d.vtt", idx))
- tmp[idx] = f
- // One output file per stream; output options precede each output path.
- args = append(args, "-map", fmt.Sprintf("0:s:%d?", idx), "-c:s", "webvtt", "-f", "webvtt", "-y", f)
- }
-
- cmd := exec.CommandContext(ctx, ffmpegPath, args...)
- var stderr strings.Builder
- cmd.Stderr = &stderr
- // Run it at IDLE I/O priority: this single ~14 min sequential read of a huge
- // remux must not starve live streaming off the same disk/NFS.
- if err := cmd.Start(); err != nil {
- return nil, fmt.Errorf("ffmpeg multi-subtitle start: %w", err)
- }
- setIdleIOPriority(cmd.Process.Pid)
- runErr := cmd.Wait()
-
- // If ffmpeg was KILLED (ctx deadline/cancel on a file too big to finish in
- // time), any temp file it left is a truncated WebVTT — a valid header plus
- // partial cues, so it passes the len>0 check and would be cached as a
- // silently-incomplete track until the media's mtime changes. Distrust all
- // output in that case. A clean non-zero exit (e.g. one empty/corrupt stream)
- // still leaves good complete files for the other tracks, so we keep those.
- var exitErr *exec.ExitError
- killed := runErr != nil && errors.As(runErr, &exitErr) && !exitErr.ProcessState.Exited()
-
- out := make(map[int][]byte, len(indices))
- if !killed {
- for idx, f := range tmp {
- if b, rerr := os.ReadFile(f); rerr == nil && len(b) > 0 {
- out[idx] = b
- }
- }
- }
- if len(out) == 0 {
- return nil, fmt.Errorf("ffmpeg multi-subtitle extract: no usable output (err=%v): %s", runErr, strings.TrimSpace(stderr.String()))
- }
- return out, nil
-}
-
-// ReadCachedThumbnail returns the cached JPEG for (mediaPath, posSec, width) when
-// a fresh sidecar exists. ok=false means extract on demand.
-func ReadCachedThumbnail(mediaPath string, posSec float64, width int) ([]byte, bool) {
- p := thumbnailCachePath(mediaPath, posSec, width)
- if !sidecarFresh(p, mediaPath) {
- return nil, false
- }
- b, err := os.ReadFile(p)
- if err != nil || len(b) == 0 {
- return nil, false
- }
- return b, true
-}
-
-// WriteCachedThumbnail stores an extracted JPEG frame next to the media. Best-effort.
-func WriteCachedThumbnail(mediaPath string, posSec float64, width int, jpeg []byte) error {
- return writeSidecar(thumbnailCachePath(mediaPath, posSec, width), jpeg)
-}
-
-// ExtractThumbnailJPEG decodes ONE frame at posSec, scaled to `width`, as JPEG
-// bytes. The fast path mirrors engine.buildThumbnailArgs (`-ss` before `-i` =
-// fast input/keyframe seek); on a seek-index failure both this prewarm path and
-// the on-demand handler fall back to the identical output-seek argv
-// (thumbnailArgsAccurate / engine.buildThumbnailArgsAccurate), so the two stay
-// equivalent in both paths. Shared by the prewarm; the handler keeps its own
-// inline extraction (engine package) and only reuses the cache helpers here.
-func ExtractThumbnailJPEG(ctx context.Context, ffmpegPath, mediaPath string, posSec float64, width int) ([]byte, error) {
- // Fast path: input seek (-ss before -i) — near-constant time, the common case.
- out, err := runThumbnailFFmpeg(ctx, ffmpegPath, thumbnailArgsFast(mediaPath, posSec, width))
- if err == nil {
- return out, nil
- }
- // Fallback: output seek (-ss after -i) + error tolerance. Slower (decodes
- // from the start) but robust on files whose seek index is imprecise or
- // mildly corrupt, where the fast input seek lands mid-EBML element
- // ("invalid as first byte of an EBML number") and yields no frame
- // (2026-06-03: anime MKVs failed every prewarm thumbnail). Paid only when
- // the fast path fails, so healthy files keep the cheap path.
- out, err2 := runThumbnailFFmpeg(ctx, ffmpegPath, thumbnailArgsAccurate(mediaPath, posSec, width))
- if err2 == nil {
- return out, nil
- }
- return nil, fmt.Errorf("ffmpeg thumbnail extract: %w (output-seek fallback: %v)", err, err2)
-}
-
-// thumbnailArgsFast is the input-seek (fast keyframe) thumbnail argv. Mirrors
-// engine.buildThumbnailArgs so prewarm frames match the on-demand handler.
-func thumbnailArgsFast(mediaPath string, posSec float64, width int) []string {
- return []string{
- "-nostdin",
- "-loglevel", "error",
- "-ss", strconv.FormatFloat(posSec, 'f', 3, 64),
- "-i", mediaPath,
- "-frames:v", "1",
- "-vf", fmt.Sprintf("scale=%d:-2", width),
- "-an", "-sn",
- "-f", "mjpeg",
- "pipe:1",
- }
-}
-
-// thumbnailArgsAccurate is the output-seek (decode-from-start) fallback with
-// error tolerance. Mirrors engine.buildThumbnailArgsAccurate.
-func thumbnailArgsAccurate(mediaPath string, posSec float64, width int) []string {
- return []string{
- "-nostdin",
- "-loglevel", "error",
- "-err_detect", "ignore_err",
- "-i", mediaPath,
- "-ss", strconv.FormatFloat(posSec, 'f', 3, 64),
- "-frames:v", "1",
- "-vf", fmt.Sprintf("scale=%d:-2", width),
- "-an", "-sn",
- "-f", "mjpeg",
- "pipe:1",
- }
-}
-
-// runThumbnailFFmpeg runs one ffmpeg thumbnail extraction and returns the JPEG
-// bytes. setIdleIOPriority keeps the background prewarm from starving live
-// playback I/O. An empty output (e.g. seek past EOF) is treated as an error so
-// the caller can fall back.
-func runThumbnailFFmpeg(ctx context.Context, ffmpegPath string, args []string) ([]byte, error) {
- cmd := exec.CommandContext(ctx, ffmpegPath, args...)
- var stdout, stderr bytes.Buffer
- cmd.Stdout = &stdout
- cmd.Stderr = &stderr
- if err := cmd.Start(); err != nil {
- return nil, fmt.Errorf("ffmpeg thumbnail start: %w", err)
- }
- setIdleIOPriority(cmd.Process.Pid) // background prewarm yields I/O to live playback
- err := cmd.Wait()
- out := stdout.Bytes()
- if err != nil {
- return nil, fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
- }
- if len(out) == 0 {
- return nil, errors.New("ffmpeg produced no thumbnail (seek past EOF?)")
- }
- return out, nil
-}
diff --git a/internal/library/mediainfo/sidecar_subs.go b/internal/library/mediainfo/sidecar_subs.go
deleted file mode 100644
index 94ec84b..0000000
--- a/internal/library/mediainfo/sidecar_subs.go
+++ /dev/null
@@ -1,207 +0,0 @@
-package mediainfo
-
-import (
- "os"
- "path/filepath"
- "strings"
-)
-
-// External (sidecar) subtitle discovery.
-//
-// A huge share of torrents — anime fansubs especially — ship subtitles as
-// SEPARATE files, not embedded streams: a `.srt`/`.ass` named after the video,
-// or a bundle inside a `Subs/` (or `Subtitles/`) subfolder. ffprobe on the video
-// container never sees these, so the scan recorded zero subtitles for them
-// (e.g. ToonsHub "MSubs" releases). This module finds those files so they become
-// real, selectable tracks served via the /sub endpoint (path-based, i=-1).
-//
-// Only TEXT formats are surfaced (srt/ass/ssa/vtt, and a lone .sub). VobSub
-// (.idx + .sub) is bitmap — no text form — so it's skipped here; bitmap subs are
-// burn-in only and external bitmap burn-in isn't wired.
-
-// subFolderNames are common subfolder names that hold a release's subtitle
-// bundle. Matched case-insensitively. Files inside belong to the sibling media.
-var subFolderNames = map[string]bool{
- "subs": true, "subtitles": true, "sub": true, "subtitle": true,
-}
-
-// sidecarSubExts maps a subtitle file extension to its ffmpeg-style codec name.
-// The codec drives the web's text-vs-bitmap classification (isTextSubtitleCodec).
-var sidecarSubExts = map[string]string{
- ".srt": "subrip",
- ".ass": "ass",
- ".ssa": "ssa",
- ".vtt": "webvtt",
- ".sub": "subrip", // MicroDVD/text — UNLESS paired with a .idx (VobSub, handled below)
-}
-
-// forcedTokens / sdhTokens are filename markers that refine a sidecar's role.
-var forcedTokens = map[string]bool{"forced": true, "forzado": true, "forces": true}
-var sdhTokens = map[string]bool{"sdh": true, "cc": true, "hi": false} // "hi" is also Hindi → don't treat as SDH
-
-// sidecarLangAliases maps RELEASE-NAMING subtitle tokens (fansub/scene shorthand
-// NOT covered by the ISO 639-1/2 normaliser) to a language hint. Two things make
-// this necessary beyond NormalizeLang:
-// - Chinese SCRIPT matters for charset: Simplified (chs/sc/gb) is GBK,
-// Traditional (cht/tc/big5) is Big5 — decoding one as the other is mojibake.
-// We keep the script in the hint ("zh" vs "zh-Hant") so legacyEncodingForLang
-// picks the right code page. Anime fansubs routinely ship both.
-// - lat/latino/vostfr etc. aren't ISO at all and would fall to "und".
-//
-// Applied ONLY to sidecar filenames, not ffprobe metadata, so it can't clash with
-// the global langNormalize ("lat"→Latin there). Plain ISO codes (eng/spa/…) are
-// intentionally left to NormalizeLang.
-var sidecarLangAliases = map[string]string{
- "chs": "zh", "sc": "zh", "gb": "zh", "gbk": "zh", "hans": "zh", // Simplified → GBK
- "cht": "zh-Hant", "tc": "zh-Hant", "big5": "zh-Hant", "hant": "zh-Hant", // Traditional → Big5
- "lat": "es", "latino": "es", "esp": "es", "español": "es", "espanol": "es",
- "vostfr": "fr", "vff": "fr", "vf": "fr",
- "ptbr": "pt", "pt-br": "pt", "bra": "pt",
-}
-
-// DiscoverSidecarSubtitles finds external subtitle files for a local media file:
-// siblings named after the video, plus everything in a Subs/Subtitles subfolder.
-// Returns text tracks only, each with External=true and an absolute Path. Safe on
-// any path — returns nil if the directory can't be read (best-effort, like the
-// rest of the scan). Never call for a remote URL source (no local directory).
-//
-// NOTE: discovered sidecars are NOT deduped against embedded streams of the same
-// language. That's deliberate — a `Movie.en.srt` next to a video that also has an
-// embedded English stream is usually a DIFFERENT track (full vs SDH, retimed, or
-// a better translation), so silently dropping either would hide a choice the user
-// may want. Both surface as separate, distinctly-labelled entries.
-func DiscoverSidecarSubtitles(mediaPath string) []SubtitleTrack {
- if mediaPath == "" || strings.Contains(mediaPath, "://") {
- return nil
- }
- dir := filepath.Dir(mediaPath)
- videoBase := strings.TrimSuffix(filepath.Base(mediaPath), filepath.Ext(mediaPath))
- videoBaseLower := strings.ToLower(videoBase)
-
- var out []SubtitleTrack
- seen := make(map[string]bool) // absolute path dedupe
-
- // 1. Siblings in the media's own directory whose name starts with the video
- // base name: "Movie.srt", "Movie.en.srt", "Movie.en.forced.ass", …
- addFromDir(dir, func(name string) bool {
- return strings.HasPrefix(strings.ToLower(name), videoBaseLower)
- }, videoBase, &out, seen)
-
- // 2. A Subs/Subtitles subfolder: take EVERY subtitle file (the whole folder
- // belongs to this release). Filenames there are usually language-named
- // ("2_English.srt", "spa.ass") with no video-base prefix.
- if entries, err := os.ReadDir(dir); err == nil {
- for _, e := range entries {
- if e.IsDir() && subFolderNames[strings.ToLower(e.Name())] {
- addFromDir(filepath.Join(dir, e.Name()), func(string) bool { return true }, "", &out, seen)
- }
- }
- }
- return out
-}
-
-// addFromDir scans one directory, emitting a SubtitleTrack for each text sidecar
-// whose name passes `match`. stripPrefix (the video base, may be "") is removed
-// before parsing language/role tokens so "Movie.en.forced.srt" parses as "en"+forced.
-func addFromDir(dir string, match func(name string) bool, stripPrefix string, out *[]SubtitleTrack, seen map[string]bool) {
- entries, err := os.ReadDir(dir)
- if err != nil {
- return
- }
- // Pre-index .idx files so a paired .sub is recognised as VobSub (bitmap) and skipped.
- idxBases := make(map[string]bool)
- for _, e := range entries {
- if !e.IsDir() && strings.EqualFold(filepath.Ext(e.Name()), ".idx") {
- idxBases[strings.ToLower(strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())))] = true
- }
- }
- for _, e := range entries {
- if e.IsDir() {
- continue
- }
- name := e.Name()
- ext := strings.ToLower(filepath.Ext(name))
- codec, ok := sidecarSubExts[ext]
- if !ok || !match(name) {
- continue
- }
- // VobSub: a .sub paired with a same-named .idx is bitmap, not text. Skip.
- if ext == ".sub" && idxBases[strings.ToLower(strings.TrimSuffix(name, ext))] {
- continue
- }
- abs := filepath.Join(dir, name)
- if seen[abs] {
- continue
- }
- seen[abs] = true
-
- lang, forced, title := parseSidecarName(name, ext, stripPrefix)
- *out = append(*out, SubtitleTrack{
- Lang: lang,
- Codec: codec,
- Title: title,
- Forced: forced,
- External: true,
- Path: abs,
- })
- }
-}
-
-// parseSidecarName extracts (lang, forced, title) from a subtitle filename.
-// stripPrefix (the video base) is removed first; the remainder is tokenised on
-// common separators and scanned for a language code + role markers. Unknown →
-// lang "und". The title is a human hint ("Forced", "SDH") or "".
-func parseSidecarName(name, ext, stripPrefix string) (lang string, forced bool, title string) {
- stem := strings.TrimSuffix(name, filepath.Ext(name))
- if stripPrefix != "" && len(stem) >= len(stripPrefix) &&
- strings.EqualFold(stem[:len(stripPrefix)], stripPrefix) {
- stem = stem[len(stripPrefix):]
- }
- lang = "und"
- var roles []string
- for _, tok := range strings.FieldsFunc(stem, func(r rune) bool {
- return r == '.' || r == '_' || r == '-' || r == ' ' || r == '[' || r == ']' || r == '(' || r == ')'
- }) {
- low := strings.ToLower(strings.TrimSpace(tok))
- if low == "" {
- continue
- }
- if forcedTokens[low] {
- forced = true
- roles = append(roles, "Forced")
- continue
- }
- if v, isSDH := sdhTokens[low]; isSDH && v {
- roles = append(roles, "SDH")
- continue
- }
- // First token that maps to a real language wins. Try release-naming
- // aliases (chs/lat/…) first, then the standard ISO normaliser. NormalizeLang
- // echoes unknown input back lowercased, so accept only a mapped result
- // (different from the raw token, or already a known 2-letter code).
- if lang == "und" {
- if alias, ok := sidecarLangAliases[low]; ok {
- lang = alias
- continue
- }
- if norm := NormalizeLang(low); norm != "und" && (norm != low || len(low) == 2) && isKnownLang(norm) {
- lang = norm
- continue
- }
- }
- }
- title = strings.Join(roles, " ")
- return lang, forced, title
-}
-
-// isKnownLang reports whether code is a value present in langNormalize (i.e. a
-// real ISO 639-1 we recognise) — guards against treating a random filename token
-// ("web", "dl") as a language.
-func isKnownLang(code string) bool {
- for _, v := range langNormalize {
- if v == code {
- return true
- }
- }
- return false
-}
diff --git a/internal/library/mediainfo/sidecar_subs_test.go b/internal/library/mediainfo/sidecar_subs_test.go
deleted file mode 100644
index 073fcbf..0000000
--- a/internal/library/mediainfo/sidecar_subs_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package mediainfo
-
-import (
- "os"
- "path/filepath"
- "testing"
-)
-
-func writeFile(t *testing.T, path, content string) {
- t.Helper()
- if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
- t.Fatalf("write %s: %v", path, err)
- }
-}
-
-func findTrack(tracks []SubtitleTrack, base string) *SubtitleTrack {
- for i := range tracks {
- if filepath.Base(tracks[i].Path) == base {
- return &tracks[i]
- }
- }
- return nil
-}
-
-func TestDiscoverSidecarSubtitles_Siblings(t *testing.T) {
- dir := t.TempDir()
- video := filepath.Join(dir, "Witch.Hat.Atelier.S01E10.mkv")
- writeFile(t, video, "x")
- writeFile(t, filepath.Join(dir, "Witch.Hat.Atelier.S01E10.srt"), "1\n00:00:01,000 --> 00:00:02,000\nhi\n")
- writeFile(t, filepath.Join(dir, "Witch.Hat.Atelier.S01E10.es.ass"), "[Script Info]")
- writeFile(t, filepath.Join(dir, "Witch.Hat.Atelier.S01E10.en.forced.srt"), "x")
- // Unrelated file with a different base must NOT be matched as a sibling.
- writeFile(t, filepath.Join(dir, "Other.Movie.srt"), "x")
-
- tracks := DiscoverSidecarSubtitles(video)
- if len(tracks) != 3 {
- t.Fatalf("want 3 sibling tracks, got %d: %+v", len(tracks), tracks)
- }
- for _, tr := range tracks {
- if !tr.External || tr.Path == "" {
- t.Errorf("track not marked external w/ path: %+v", tr)
- }
- }
- if es := findTrack(tracks, "Witch.Hat.Atelier.S01E10.es.ass"); es == nil || es.Lang != "es" || es.Codec != "ass" {
- t.Errorf("es.ass mis-parsed: %+v", es)
- }
- if fr := findTrack(tracks, "Witch.Hat.Atelier.S01E10.en.forced.srt"); fr == nil || fr.Lang != "en" || !fr.Forced {
- t.Errorf("forced track mis-parsed: %+v", fr)
- }
-}
-
-func TestDiscoverSidecarSubtitles_SubsFolder(t *testing.T) {
- dir := t.TempDir()
- video := filepath.Join(dir, "Movie.2024.1080p.mkv")
- writeFile(t, video, "x")
- subs := filepath.Join(dir, "Subs")
- if err := os.Mkdir(subs, 0o755); err != nil {
- t.Fatal(err)
- }
- writeFile(t, filepath.Join(subs, "2_English.srt"), "x")
- writeFile(t, filepath.Join(subs, "spa.ass"), "x")
-
- tracks := DiscoverSidecarSubtitles(video)
- if len(tracks) != 2 {
- t.Fatalf("want 2 Subs/ tracks, got %d: %+v", len(tracks), tracks)
- }
- if en := findTrack(tracks, "2_English.srt"); en == nil || en.Lang != "en" {
- t.Errorf("English mis-parsed: %+v", en)
- }
- if es := findTrack(tracks, "spa.ass"); es == nil || es.Lang != "es" {
- t.Errorf("spa mis-parsed: %+v", es)
- }
-}
-
-func TestParseSidecarName_ReleaseAliases(t *testing.T) {
- cases := []struct {
- name, ext, prefix, wantLang string
- }{
- {"[DMG] Orange [01].chs.ass", ".ass", "", "zh"}, // Chinese Simplified fansub code → GBK
- {"Show.cht.srt", ".srt", "Show", "zh-Hant"}, // Chinese Traditional → Big5
- {"Movie.big5.srt", ".srt", "Movie", "zh-Hant"}, // Traditional via codepage token
- {"Movie.lat.srt", ".srt", "Movie", "es"}, // Latin-American Spanish
- {"Movie.latino.srt", ".srt", "Movie", "es"}, //
- {"Pelicula.esp.srt", ".srt", "Pelicula", "es"}, //
- {"Anime.VOSTFR.ass", ".ass", "Anime", "fr"}, // French fansub
- {"X.kan.srt", ".srt", "X", "kn"}, // Kannada via langNormalize add
- {"X.mal.srt", ".srt", "X", "ml"}, // Malayalam
- }
- for _, c := range cases {
- lang, _, _ := parseSidecarName(c.name, c.ext, c.prefix)
- if lang != c.wantLang {
- t.Errorf("%s: got lang %q, want %q", c.name, lang, c.wantLang)
- }
- }
-}
-
-func TestDiscoverSidecarSubtitles_VobSubSkipped(t *testing.T) {
- dir := t.TempDir()
- video := filepath.Join(dir, "Film.mkv")
- writeFile(t, video, "x")
- writeFile(t, filepath.Join(dir, "Film.idx"), "x")
- writeFile(t, filepath.Join(dir, "Film.sub"), "x") // VobSub bitmap → skip
- tracks := DiscoverSidecarSubtitles(video)
- if len(tracks) != 0 {
- t.Fatalf("VobSub .sub+.idx must be skipped, got %d: %+v", len(tracks), tracks)
- }
-}
-
-func TestDiscoverSidecarSubtitles_RemoteURLNoop(t *testing.T) {
- if tracks := DiscoverSidecarSubtitles("https://example.com/movie.mkv"); tracks != nil {
- t.Fatalf("remote URL must yield no sidecars, got %+v", tracks)
- }
-}
diff --git a/internal/library/mediainfo/sidecar_test.go b/internal/library/mediainfo/sidecar_test.go
deleted file mode 100644
index 87306b2..0000000
--- a/internal/library/mediainfo/sidecar_test.go
+++ /dev/null
@@ -1,129 +0,0 @@
-package mediainfo
-
-import (
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-)
-
-func TestIsTextSubtitleCodec(t *testing.T) {
- text := []string{"subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text", "SubRip", " ASS "}
- bitmap := []string{"hdmv_pgs_subtitle", "dvd_subtitle", "dvb_subtitle", "", " ", "weirdcodec"}
- for _, c := range text {
- if !IsTextSubtitleCodec(c) {
- t.Errorf("IsTextSubtitleCodec(%q) = false, want true", c)
- }
- }
- for _, c := range bitmap {
- if IsTextSubtitleCodec(c) {
- t.Errorf("IsTextSubtitleCodec(%q) = true, want false", c)
- }
- }
-}
-
-func TestSubtitleCachePath(t *testing.T) {
- got := subtitleCachePath("/movies/Foo Bar.mkv", 3)
- want := filepath.Join("/movies", ".unarr", "Foo Bar.mkv.s3.vtt")
- if got != want {
- t.Errorf("subtitleCachePath = %q, want %q", got, want)
- }
-}
-
-func TestThumbnailCachePath(t *testing.T) {
- cases := []struct {
- pos float64
- width int
- want string
- }{
- {84.0, 320, "Foo.mkv.t84w320.jpg"},
- {84.3, 320, "Foo.mkv.t84w320.jpg"}, // rounds to whole seconds
- {84.6, 320, "Foo.mkv.t85w320.jpg"},
- {-5, 320, "Foo.mkv.t0w320.jpg"}, // negative clamps to 0
- }
- for _, c := range cases {
- got := thumbnailCachePath("/m/Foo.mkv", c.pos, c.width)
- want := filepath.Join("/m", ".unarr", c.want)
- if got != want {
- t.Errorf("thumbnailCachePath(%.1f,%d) = %q, want %q", c.pos, c.width, got, want)
- }
- }
-}
-
-func TestSidecarDirIsPerFolder(t *testing.T) {
- // Two files with the SAME basename in different dirs must not collide.
- a := subtitleCachePath("/a/Movie.mkv", 0)
- b := subtitleCachePath("/b/Movie.mkv", 0)
- if a == b {
- t.Errorf("same-basename files in different dirs collided: %q", a)
- }
- if filepath.Base(filepath.Dir(a)) != ".unarr" {
- t.Errorf("sidecar not in .unarr dir: %q", a)
- }
-}
-
-func TestSidecarFresh(t *testing.T) {
- dir := t.TempDir()
- media := filepath.Join(dir, "m.mkv")
- cache := filepath.Join(dir, "m.cache")
- if err := os.WriteFile(media, []byte("media"), 0o644); err != nil {
- t.Fatal(err)
- }
-
- // No cache file yet → not fresh.
- if sidecarFresh(cache, media) {
- t.Error("missing cache reported fresh")
- }
-
- // Cache newer than media → fresh.
- if err := os.WriteFile(cache, []byte("vtt"), 0o644); err != nil {
- t.Fatal(err)
- }
- future := time.Now().Add(time.Hour)
- if err := os.Chtimes(cache, future, future); err != nil {
- t.Fatal(err)
- }
- if !sidecarFresh(cache, media) {
- t.Error("cache newer than media reported stale")
- }
-
- // Media re-downloaded (newer than cache) → stale.
- newer := time.Now().Add(2 * time.Hour)
- if err := os.Chtimes(media, newer, newer); err != nil {
- t.Fatal(err)
- }
- if sidecarFresh(cache, media) {
- t.Error("cache older than media reported fresh")
- }
-
- // Missing media → not fresh (don't serve a sidecar for a vanished file).
- if sidecarFresh(cache, filepath.Join(dir, "gone.mkv")) {
- t.Error("missing media reported fresh")
- }
-}
-
-func TestWriteSidecarAtomicAndRejectsEmpty(t *testing.T) {
- dir := t.TempDir()
- p := filepath.Join(dir, "sub", ".unarr", "x.s0.vtt")
-
- if err := writeSidecar(p, nil); err == nil {
- t.Error("writeSidecar accepted empty data")
- }
-
- data := []byte("WEBVTT\n\n00:00.000 --> 00:01.000\nhi\n")
- if err := writeSidecar(p, data); err != nil {
- t.Fatalf("writeSidecar: %v", err)
- }
- got, err := os.ReadFile(p)
- if err != nil || string(got) != string(data) {
- t.Errorf("written sidecar mismatch: %q err=%v", got, err)
- }
- // No leftover temp file.
- if _, err := os.Stat(p + ".tmp"); !os.IsNotExist(err) {
- t.Errorf("temp file not cleaned up")
- }
- if !strings.HasSuffix(p, ".vtt") {
- t.Errorf("unexpected path: %q", p)
- }
-}
diff --git a/internal/library/mediainfo/trickplay.go b/internal/library/mediainfo/trickplay.go
deleted file mode 100644
index f134be6..0000000
--- a/internal/library/mediainfo/trickplay.go
+++ /dev/null
@@ -1,324 +0,0 @@
-package mediainfo
-
-import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "math"
- "os"
- "os/exec"
- "path/filepath"
- "runtime"
- "strconv"
- "strings"
- "time"
-)
-
-// ErrTrickplayInProgress means another worker — possibly an agent on another host
-// sharing the same library (e.g. the dev binary on /mnt/nas and the docker agent
-// on /downloads, the SAME files) — already holds this sprite's lock and is
-// generating it. The caller must SKIP, not count it as a failure.
-var ErrTrickplayInProgress = errors.New("trickplay: generation already in progress")
-
-// trickplayLockTTL bounds a stale lock: longer than the caller's 45-min generation
-// deadline so a live job is never stolen, short enough that a crashed/killed
-// worker's lock is reclaimed on a later scan.
-const trickplayLockTTL = 90 * time.Minute
-
-// acquireTrickplayLock takes an exclusive, cross-process lock for one sprite by
-// O_CREATE|O_EXCL on a ".lock" file in the shared sidecar dir, so two agents that
-// watch the same library never decode the same 4K file at once (the cause of the
-// 5×-per-file ffmpeg pile-up). A lock older than trickplayLockTTL is assumed
-// abandoned (owner crashed) and reclaimed. Returns ErrTrickplayInProgress when a
-// fresh lock is held by someone else.
-func acquireTrickplayLock(lockPath string) (func(), error) {
- for attempt := 0; attempt < 2; attempt++ {
- f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
- if err == nil {
- host, _ := os.Hostname()
- fmt.Fprintf(f, "%s pid=%d t=%d\n", host, os.Getpid(), time.Now().Unix())
- _ = f.Close()
- return func() { _ = os.Remove(lockPath) }, nil
- }
- if !os.IsExist(err) {
- return nil, fmt.Errorf("trickplay lock: %w", err)
- }
- if fi, statErr := os.Stat(lockPath); statErr == nil && time.Since(fi.ModTime()) > trickplayLockTTL {
- _ = os.Remove(lockPath) // stale → reclaim and retry
- continue
- }
- return nil, ErrTrickplayInProgress
- }
- return nil, ErrTrickplayInProgress
-}
-
-// TrickplayManifest describes the montage sprite layout so a client can map a
-// playback time to one tile: tileIndex = floor(timeSec / IntervalSec), then
-// col = tileIndex % Cols, row = tileIndex / Cols, and the tile's pixel box is
-// (col*TileWidth, row*TileHeight, TileWidth, TileHeight).
-type TrickplayManifest struct {
- Version int `json:"version"` // schema version (1)
- IntervalSec float64 `json:"intervalSec"`
- TileWidth int `json:"tileWidth"`
- TileHeight int `json:"tileHeight"`
- Cols int `json:"cols"`
- Rows int `json:"rows"`
- Count int `json:"count"` // number of REAL frames (≤ Cols*Rows; the rest are padding)
- DurationSec float64 `json:"durationSec"`
-}
-
-// trickplaySpritePath / trickplayManifestPath include the tile width so changing
-// library.trickplay.width regenerates cleanly instead of serving a stale sprite.
-func trickplaySpritePath(mediaPath string, width int) string {
- return filepath.Join(sidecarDir(mediaPath), fmt.Sprintf("%s.trickplay.w%d.jpg", filepath.Base(mediaPath), width))
-}
-
-func trickplayManifestPath(mediaPath string, width int) string {
- return filepath.Join(sidecarDir(mediaPath), fmt.Sprintf("%s.trickplay.w%d.json", filepath.Base(mediaPath), width))
-}
-
-// TrickplaySpritePath is the public accessor the stream server uses to locate the
-// cached sprite JPEG for serving.
-func TrickplaySpritePath(mediaPath string, width int) string {
- return trickplaySpritePath(mediaPath, width)
-}
-
-// ReadCachedTrickplay returns the manifest when a fresh sprite + manifest exist
-// for (mediaPath, width). ok=false means the caller should (re)generate. Both
-// the sprite and the manifest must be at least as new as the media file.
-func ReadCachedTrickplay(mediaPath string, width int) (TrickplayManifest, bool) {
- sprite := trickplaySpritePath(mediaPath, width)
- manifest := trickplayManifestPath(mediaPath, width)
- if !sidecarFresh(sprite, mediaPath) || !sidecarFresh(manifest, mediaPath) {
- return TrickplayManifest{}, false
- }
- b, err := os.ReadFile(manifest)
- if err != nil || len(b) == 0 {
- return TrickplayManifest{}, false
- }
- var m TrickplayManifest
- if err := json.Unmarshal(b, &m); err != nil || m.Cols <= 0 || m.TileWidth <= 0 {
- return TrickplayManifest{}, false
- }
- return m, true
-}
-
-// GenerateTrickplay builds the montage sprite + manifest for mediaPath and caches
-// them in the sidecar dir. ONE ffmpeg pass samples a frame every intervalSec
-// (fps=1/interval), scales each to width (even height), and tiles them into a
-// single JPEG.
-//
-// `-skip_frame nokey` makes the decoder touch ONLY keyframes — ~12× less CPU
-// than the old full decode (measured 233 s → 19 s CPU on a 24-min 1080p
-// episode), which matters because this runs alongside live streaming on the
-// same box. The fps filter still emits one frame per UNIFORM tick (it
-// repeats the latest keyframe for ticks between keyframes), so the manifest
-// contract — tileIndex = floor(t / IntervalSec) — is unchanged and cached
-// clients keep working; each tile just shows the nearest keyframe ≤ its
-// tick (≤ one GOP off, invisible at 240-320 px scrub size).
-//
-// durationSec drives the grid size; pass the probed duration (0 → error, nothing
-// to sample). The caller owns the ctx deadline (generous at scan time).
-func GenerateTrickplay(ctx context.Context, ffmpegPath, mediaPath string, intervalSec float64, width int, durationSec float64) (TrickplayManifest, error) {
- if ffmpegPath == "" {
- return TrickplayManifest{}, fmt.Errorf("trickplay: no ffmpeg")
- }
- if intervalSec <= 0 || width <= 0 {
- return TrickplayManifest{}, fmt.Errorf("trickplay: invalid interval=%v width=%d", intervalSec, width)
- }
- if durationSec <= 0 {
- return TrickplayManifest{}, fmt.Errorf("trickplay: unknown duration")
- }
-
- // fps=1/interval emits a frame at t=0, interval, 2*interval, … while t <
- // duration → ceil(duration/interval) frames. (An earlier floor(...)+1 put a
- // black padding tile at the very end of the scrubber on round-duration media.)
- effInterval := intervalSec
- count := int(math.Ceil(durationSec / effInterval))
- if count < 1 {
- count = 1
- }
-
- // Mobile decode cap: a single JPEG above ~16.7M px (4096²) fails to decode on
- // iOS/Safari. For long media, sample fewer frames (coarser effective interval)
- // so ONE sprite stays renderable everywhere. tileH is unknown until probe, so
- // estimate from 16:9 for the budget; the manifest reports effInterval so the
- // client maps time→tile correctly.
- const maxSpritePixels = 16_000_000
- estTileH := width * 9 / 16
- if estTileH < 1 {
- estTileH = 1
- }
- if maxTiles := maxSpritePixels / (width * estTileH); maxTiles >= 1 && count > maxTiles {
- effInterval = durationSec / float64(maxTiles)
- count = int(math.Ceil(durationSec / effInterval))
- if count > maxTiles {
- count = maxTiles // guard ceil rounding
- }
- }
-
- // Roughly-square grid. Cols*Rows ≥ count; trailing cells are ffmpeg padding,
- // and Count tells the client how many are real.
- cols := int(math.Ceil(math.Sqrt(float64(count))))
- if cols < 1 {
- cols = 1
- }
- rows := int(math.Ceil(float64(count) / float64(cols)))
- if rows < 1 {
- rows = 1
- }
-
- spritePath := trickplaySpritePath(mediaPath, width)
- manifestPath := trickplayManifestPath(mediaPath, width)
- if err := os.MkdirAll(filepath.Dir(spritePath), 0o755); err != nil {
- return TrickplayManifest{}, err
- }
-
- // Single-flight across processes/agents: only one worker decodes this file at
- // a time. Returns ErrTrickplayInProgress (skip, not fail) if another holds it.
- release, err := acquireTrickplayLock(spritePath + ".lock")
- if err != nil {
- return TrickplayManifest{}, err
- }
- defer release()
-
- tmpSprite := spritePath + ".tmp"
-
- // fps filter wants a rational; format 1/effInterval with enough precision.
- // eof_action=pass: with -skip_frame nokey a short/all-inter clip can decode
- // to a SINGLE keyframe, and fps's default eof handling emits zero frames
- // from a one-frame stream (it never sees a later PTS to close the first
- // tick) → "Nothing was written into output". pass flushes the last frame
- // at EOF instead; on normal media it only matters at the very end, where
- // -frames:v 1 + the tile grid already bound the output.
- fps := fmt.Sprintf("1/%s", strconv.FormatFloat(effInterval, 'f', 3, 64))
- vf := fmt.Sprintf("fps=%s:eof_action=pass,scale=%d:-2,tile=%dx%d", fps, width, cols, rows)
- args := []string{
- "-nostdin", "-loglevel", "error", "-y",
- // Decoder-level keyframe-only mode — must precede -i (input option).
- "-skip_frame", "nokey",
- "-i", mediaPath,
- "-frames:v", "1",
- "-vf", vf,
- "-an", "-sn",
- "-q:v", "5",
- // Force the muxer: the temp output ends in ".tmp", so ffmpeg can't infer
- // the format from the extension (it would error "Unable to choose an
- // output format"). mjpeg writes the single montage frame as a JPEG.
- "-f", "mjpeg",
- tmpSprite,
- }
- // Pin this goroutine to its OS thread for the whole child lifetime. hardenCmd's
- // Pdeathsig is delivered when the THREAD that forked dies, not the process
- // (golang/go#27505); without the lock Go could recycle that thread mid-decode
- // and the kernel would SIGKILL a perfectly healthy ffmpeg. Locked here (before
- // the fork in Start) and released after Wait, the thread lives exactly as long
- // as ffmpeg: it dies only when the agent process itself dies → SIGKILL fires
- // only then, which is precisely the orphan we want to prevent.
- runtime.LockOSThread()
- defer runtime.UnlockOSThread()
-
- cmd := exec.CommandContext(ctx, ffmpegPath, args...)
- var stderr strings.Builder
- cmd.Stderr = &stderr
- // Die-with-parent BEFORE Start so an agent crash can't orphan this decode.
- hardenCmd(cmd)
- // Start + idle I/O + lowest CPU niceness + Wait (matches the subtitle/thumbnail
- // extractors): this full-decode pass is the heaviest sidecar job and runs in the
- // background alongside live streaming on the same box/NFS, so it must yield both
- // disk AND CPU. The prewarm also gates it on system load before getting here.
- if err := cmd.Start(); err != nil {
- _ = os.Remove(tmpSprite)
- return TrickplayManifest{}, fmt.Errorf("ffmpeg tile start: %w", err)
- }
- setIdleIOPriority(cmd.Process.Pid)
- setLowCPUPriority(cmd.Process.Pid)
- if err := cmd.Wait(); err != nil {
- _ = os.Remove(tmpSprite)
- return TrickplayManifest{}, fmt.Errorf("ffmpeg tile: %w: %s", err, strings.TrimSpace(stderr.String()))
- }
- if fi, err := os.Stat(tmpSprite); err != nil || fi.Size() == 0 {
- _ = os.Remove(tmpSprite)
- return TrickplayManifest{}, fmt.Errorf("trickplay: empty sprite")
- }
-
- // Probe the produced sprite for EXACT dimensions, so tile geometry is precise
- // (avoids ±1px aspect-rounding drift between our math and ffmpeg's scale=-2).
- spriteW, spriteH, err := probeImageDims(ctx, ffmpegPath, tmpSprite)
- if err != nil || spriteW < cols || spriteH < rows {
- _ = os.Remove(tmpSprite)
- return TrickplayManifest{}, fmt.Errorf("trickplay: probe sprite dims: %w", err)
- }
- m := TrickplayManifest{
- Version: 1,
- IntervalSec: effInterval,
- TileWidth: spriteW / cols,
- TileHeight: spriteH / rows,
- Cols: cols,
- Rows: rows,
- Count: count,
- DurationSec: durationSec,
- }
- mb, err := json.Marshal(m)
- if err != nil {
- _ = os.Remove(tmpSprite)
- return TrickplayManifest{}, err
- }
- // Publish sprite (rename) then manifest (atomic write). Order: sprite first so
- // a reader that sees a fresh manifest always finds the matching sprite.
- if err := os.Rename(tmpSprite, spritePath); err != nil {
- _ = os.Remove(tmpSprite)
- return TrickplayManifest{}, err
- }
- if err := writeSidecar(manifestPath, mb); err != nil {
- return TrickplayManifest{}, err
- }
- return m, nil
-}
-
-// probeImageDims returns the pixel width/height of an image file via ffmpeg's
-// bundled ffprobe-less path: we reuse ffmpeg with -hide_banner and parse the
-// "Stream ... WxH" line from stderr. Using ffmpeg (already resolved) avoids a
-// hard dependency on a separate ffprobe binary here.
-func probeImageDims(ctx context.Context, ffmpegPath, path string) (int, int, error) {
- cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-i", path)
- var stderr strings.Builder
- cmd.Stderr = &stderr
- _ = cmd.Run() // ffmpeg exits non-zero with no output file; we only want the probe stderr
- return parseDims(stderr.String())
-}
-
-// parseDims extracts the first WxH (e.g. "3840x2160") from ffmpeg's stream info.
-func parseDims(s string) (int, int, error) {
- idx := strings.Index(s, "Video:")
- if idx < 0 {
- return 0, 0, fmt.Errorf("no video stream in probe output")
- }
- // Scan for the first "x" token after "Video:".
- rest := s[idx:]
- for i := 0; i < len(rest); i++ {
- if rest[i] < '0' || rest[i] > '9' {
- continue
- }
- j := i
- for j < len(rest) && rest[j] >= '0' && rest[j] <= '9' {
- j++
- }
- if j < len(rest) && rest[j] == 'x' {
- k := j + 1
- for k < len(rest) && rest[k] >= '0' && rest[k] <= '9' {
- k++
- }
- if k > j+1 {
- w, _ := strconv.Atoi(rest[i:j])
- h, _ := strconv.Atoi(rest[j+1 : k])
- if w > 0 && h > 0 {
- return w, h, nil
- }
- }
- }
- i = j
- }
- return 0, 0, fmt.Errorf("no WxH token in probe output")
-}
diff --git a/internal/library/mediainfo/trickplay_lock_test.go b/internal/library/mediainfo/trickplay_lock_test.go
deleted file mode 100644
index 41c94b0..0000000
--- a/internal/library/mediainfo/trickplay_lock_test.go
+++ /dev/null
@@ -1,67 +0,0 @@
-package mediainfo
-
-import (
- "errors"
- "os"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestAcquireTrickplayLock_SingleFlight(t *testing.T) {
- lock := filepath.Join(t.TempDir(), "sprite.jpg.lock")
-
- release, err := acquireTrickplayLock(lock)
- if err != nil {
- t.Fatalf("first acquire: %v", err)
- }
- if _, statErr := os.Stat(lock); statErr != nil {
- t.Fatalf("lock file not created: %v", statErr)
- }
-
- // Second acquire while the first is held → skip sentinel, not a real error.
- if _, err := acquireTrickplayLock(lock); !errors.Is(err, ErrTrickplayInProgress) {
- t.Fatalf("expected ErrTrickplayInProgress, got %v", err)
- }
-
- // After release the lock file is gone and it can be re-acquired.
- release()
- if _, statErr := os.Stat(lock); !os.IsNotExist(statErr) {
- t.Fatalf("lock file should be removed after release, stat err = %v", statErr)
- }
- release2, err := acquireTrickplayLock(lock)
- if err != nil {
- t.Fatalf("re-acquire after release: %v", err)
- }
- release2()
-}
-
-func TestAcquireTrickplayLock_ReclaimsStale(t *testing.T) {
- lock := filepath.Join(t.TempDir(), "sprite.jpg.lock")
-
- // Simulate a crashed worker: a lock file older than the TTL with no live owner.
- if err := os.WriteFile(lock, []byte("deadhost pid=999 t=0\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- old := time.Now().Add(-trickplayLockTTL - time.Minute)
- if err := os.Chtimes(lock, old, old); err != nil {
- t.Fatal(err)
- }
-
- release, err := acquireTrickplayLock(lock)
- if err != nil {
- t.Fatalf("stale lock should be reclaimed, got %v", err)
- }
- release()
-}
-
-func TestAcquireTrickplayLock_FreshNotReclaimed(t *testing.T) {
- lock := filepath.Join(t.TempDir(), "sprite.jpg.lock")
- if err := os.WriteFile(lock, []byte("livehost pid=123 t=now\n"), 0o644); err != nil {
- t.Fatal(err)
- }
- // Fresh mtime (just written) → a live owner is assumed; must NOT be stolen.
- if _, err := acquireTrickplayLock(lock); !errors.Is(err, ErrTrickplayInProgress) {
- t.Fatalf("fresh lock must not be reclaimed, got %v", err)
- }
-}
diff --git a/internal/library/mediainfo/trickplay_test.go b/internal/library/mediainfo/trickplay_test.go
deleted file mode 100644
index 754022d..0000000
--- a/internal/library/mediainfo/trickplay_test.go
+++ /dev/null
@@ -1,107 +0,0 @@
-package mediainfo
-
-import (
- "context"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "testing"
- "time"
-)
-
-func TestParseDims(t *testing.T) {
- cases := []struct {
- in string
- w, h int
- ok bool
- }{
- {"Stream #0:0: Video: mjpeg, yuvj420p(pc), 720x270 [SAR 1:1 DAR 8:3]", 720, 270, true},
- {" Stream #0:0: Video: h264 (High), yuv420p, 3840x2160, 23.98 fps", 3840, 2160, true},
- {"Stream #0:1: Audio: aac, 48000 Hz, stereo", 0, 0, false}, // no Video:
- {"", 0, 0, false},
- }
- for _, c := range cases {
- w, h, err := parseDims(c.in)
- if c.ok {
- if err != nil || w != c.w || h != c.h {
- t.Errorf("parseDims(%q) = %d,%d,%v; want %d,%d,nil", c.in, w, h, err, c.w, c.h)
- }
- } else if err == nil {
- t.Errorf("parseDims(%q) expected error, got %dx%d", c.in, w, h)
- }
- }
-}
-
-// makeClip writes a synthetic 16:9 test clip of the given duration (seconds).
-func makeClip(t *testing.T, ff, path string, durSec int) {
- t.Helper()
- mk := exec.Command(ff, "-nostdin", "-loglevel", "error", "-y",
- "-f", "lavfi", "-i", fmt.Sprintf("testsrc=duration=%d:size=640x360:rate=10", durSec),
- "-pix_fmt", "yuv420p", path)
- if out, err := mk.CombinedOutput(); err != nil {
- t.Fatalf("make test clip: %v: %s", err, out)
- }
-}
-
-// TestGenerateTrickplay builds synthetic clips and asserts the sprite grid +
-// manifest. ffmpeg-gated (skips without it, like the encode benchmark).
-func TestGenerateTrickplay(t *testing.T) {
- ff, err := exec.LookPath("ffmpeg")
- if err != nil {
- t.Skip("ffmpeg not on PATH")
- }
-
- cases := []struct {
- name string
- durSec int
- wantCount int
- wantCols, wantRows int
- }{
- // fps=1/10 emits a frame at 0,10,20,… while t 140 {
- t.Errorf("tileHeight=%d; want ~135 (16:9)", m.TileHeight)
- }
- if m.IntervalSec != 10 {
- t.Errorf("intervalSec=%v; want 10 (no cap at this size)", m.IntervalSec)
- }
- if fi, err := os.Stat(TrickplaySpritePath(clip, 240)); err != nil || fi.Size() == 0 {
- t.Errorf("sprite not written: %v", err)
- }
- m2, ok := ReadCachedTrickplay(clip, 240)
- if !ok || m2.Count != m.Count || m2.TileHeight != m.TileHeight || m2.Cols != m.Cols {
- t.Errorf("ReadCachedTrickplay mismatch: ok=%v got=%+v want=%+v", ok, m2, m)
- }
- // Stale media (newer mtime) must invalidate the cache.
- future := time.Now().Add(2 * time.Hour)
- if err := os.Chtimes(clip, future, future); err == nil {
- if _, ok := ReadCachedTrickplay(clip, 240); ok {
- t.Error("ReadCachedTrickplay returned stale sprite after media mtime bumped")
- }
- }
- })
- }
-}
diff --git a/internal/library/mediainfo/types.go b/internal/library/mediainfo/types.go
index 25153ff..bf52f80 100644
--- a/internal/library/mediainfo/types.go
+++ b/internal/library/mediainfo/types.go
@@ -6,19 +6,6 @@ type MediaInfo struct {
Audio []AudioTrack `json:"audio"`
Subtitles []SubtitleTrack `json:"subtitles"`
Languages []string `json:"languages"` // derived from audio tracks
- // Integrity is non-nil only when the scan found signs of corruption / an
- // incomplete download. Surfaced in the web library as a "damaged" warning
- // so the user re-downloads instead of hitting a file that won't play.
- Integrity *IntegrityInfo `json:"integrity,omitempty"`
-}
-
-// IntegrityInfo flags a file whose metadata probed OK enough to land in the
-// library but that shows structural damage (ffprobe emitted EBML / "invalid
-// data" errors, a truncated moov atom, or no usable video/duration) — the
-// hallmark of an incomplete or corrupt download.
-type IntegrityInfo struct {
- Damaged bool `json:"damaged"`
- Reason string `json:"reason,omitempty"`
}
// VideoInfo represents the primary video stream metadata.
@@ -42,23 +29,10 @@ type AudioTrack struct {
Default bool `json:"default"`
}
-// SubtitleTrack represents a single subtitle source — either an EMBEDDED stream
-// (the common case, identified by its ffmpeg `0:s:N` order in the slice) or an
-// EXTERNAL sidecar file sitting next to the media (Path set, External true).
-//
-// External sidecars (a `.srt`/`.ass`/`.vtt` named after the video, or one in a
-// `Subs/` subfolder) are appended AFTER all embedded tracks so the embedded
-// tracks keep slice positions equal to their `0:s:N` index — the web's
-// resolveSubtitleTracks relies on that for embedded, and switches to Path-based
-// addressing for external (served via /sub?p=&i=-1).
+// SubtitleTrack represents a single subtitle stream.
type SubtitleTrack struct {
Lang string `json:"lang"`
Codec string `json:"codec"`
Title string `json:"title"`
Forced bool `json:"forced"`
- // External is true for a sidecar file; false (omitted) for an embedded stream.
- External bool `json:"external,omitempty"`
- // Path is the absolute filesystem path of the sidecar file (External only).
- // Empty for embedded streams (those live inside the media container).
- Path string `json:"path,omitempty"`
}
diff --git a/internal/library/prewarm.go b/internal/library/prewarm.go
deleted file mode 100644
index 50b444e..0000000
--- a/internal/library/prewarm.go
+++ /dev/null
@@ -1,345 +0,0 @@
-package library
-
-import (
- "context"
- "errors"
- "log"
- "math"
- "runtime"
- "sync"
- "time"
-
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
-)
-
-// Thumbnail sampling — kept in lockstep with the web's src/lib/stream/thumbnails.ts
-// (THUMB_FRACTIONS / THUMB_FALLBACK_SECS / THUMB_WIDTH) so the frames the scan
-// pre-extracts are the exact ones the "file characteristics" panel requests.
-var (
- thumbFractions = []float64{0.1, 0.3, 0.5, 0.7, 0.9}
- thumbFallbackSec = []float64{30, 120, 300, 600, 1200}
-)
-
-const thumbWidth = 320
-
-// PrewarmOptions controls scan-time sidecar extraction.
-type PrewarmOptions struct {
- FFmpegPath string // resolved ffmpeg binary; empty disables prewarm
- CacheSubtitles bool // library.cache_subtitles
- CacheThumbnails bool // library.cache_thumbnails
- Workers int // concurrent ffmpeg jobs (each is heavy); default 2
-
- // Trickplay (library.trickplay): generate ONE montage sprite per file sampled
- // every TrickplayIntervalSec at TrickplayWidth. Replaces live scrubber
- // extraction during playback (no contention with the active stream).
- Trickplay bool
- TrickplayIntervalSec float64
- TrickplayWidth int
-
- // MaxLoadRatio gates the heavy trickplay decode on system load: a job only
- // starts while the 1-min load average is ≤ MaxLoadRatio×NumCPU, so sprite
- // generation never saturates the machine or the NAS. ≤0 → default 0.7. Has no
- // effect on platforms without a load reading (proceeds unthrottled).
- MaxLoadRatio float64
-}
-
-// prewarmJob is one extraction unit: all text subtitles of a file in one ffmpeg
-// pass (subtitle job), a single thumbnail frame (thumb=true), or the trickplay
-// montage sprite for a file (trick=true).
-type prewarmJob struct {
- path string
- thumb bool
- trick bool // trickplay sprite job
- subIdx []int // subtitle stream indices to extract in ONE pass (subtitle job)
- posSec float64 // frame position in seconds (thumbnail job)
- width int // frame/tile width (thumbnail + trickplay jobs)
- duration float64 // runtime seconds (trickplay job)
-}
-
-// PrewarmSidecars extracts text subtitles (→ WebVTT) and the panel's sample
-// thumbnail frames (→ JPEG) for every scanned item into the hidden ".unarr"
-// sidecar dir next to the media file, so the /sub and /thumbnail handlers serve
-// them instantly. Subtitle extraction without the per-request HTTP timeout is
-// what makes huge remuxes work.
-//
-// Best-effort and idempotent: fresh sidecars are skipped, errors are logged and
-// the item moves on, and ctx cancellation (Ctrl-C / daemon shutdown) stops
-// cleanly. Safe to call after every scan — only missing/stale caches do work.
-func PrewarmSidecars(ctx context.Context, cache *LibraryCache, opts PrewarmOptions) {
- if cache == nil || opts.FFmpegPath == "" || (!opts.CacheSubtitles && !opts.CacheThumbnails && !opts.Trickplay) {
- return
- }
- workers := opts.Workers
- if workers < 1 {
- workers = 2
- }
- maxLoadRatio := opts.MaxLoadRatio
- if maxLoadRatio <= 0 {
- maxLoadRatio = 0.7
- }
- // Trickplay is the heaviest job (full 4K decode). Cap it to ONE concurrent
- // decode across this agent's workers — the thumbnail/subtitle jobs (light /
- // I/O-bound) keep their `workers` parallelism. Cross-agent dup work is stopped
- // by the per-file lock inside GenerateTrickplay.
- trickSem := make(chan struct{}, 1)
-
- jobs := make(chan prewarmJob)
- var wg sync.WaitGroup
- var mu sync.Mutex
- subCached, thumbCached, trickCached, failed := 0, 0, 0, 0
- var sampleErr string // first extraction error, surfaced in the summary so a
- // systemic ffmpeg failure (vs one corrupt file) is diagnosable from "N failed".
-
- for i := 0; i < workers; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- for j := range jobs {
- if ctx.Err() != nil {
- return
- }
- if j.thumb {
- if _, ok := mediainfo.ReadCachedThumbnail(j.path, j.posSec, j.width); ok {
- continue
- }
- // A single keyframe decode is fast; 60s bounds a corrupt file.
- jctx, cancel := context.WithTimeout(ctx, 60*time.Second)
- img, err := mediainfo.ExtractThumbnailJPEG(jctx, opts.FFmpegPath, j.path, j.posSec, j.width)
- cancel()
- if err != nil { // seek past EOF / corrupt → skip
- mu.Lock()
- failed++
- if sampleErr == "" {
- sampleErr = err.Error()
- }
- mu.Unlock()
- continue
- }
- if werr := mediainfo.WriteCachedThumbnail(j.path, j.posSec, j.width, img); werr != nil {
- log.Printf("[prewarm] thumbnail write skipped (pos=%.0f path=%q): %v", j.posSec, j.path, werr)
- mu.Lock()
- failed++
- mu.Unlock()
- continue
- }
- mu.Lock()
- thumbCached++
- mu.Unlock()
- continue
- }
-
- if j.trick {
- if _, ok := mediainfo.ReadCachedTrickplay(j.path, j.width); ok {
- continue
- }
- // Serialize the heavy decode (1 at a time) and wait for the box to
- // be idle enough before starting — sprite generation must never
- // saturate the CPU or the NAS.
- select {
- case trickSem <- struct{}{}:
- case <-ctx.Done():
- return
- }
- waitForLowLoad(ctx, maxLoadRatio)
- if ctx.Err() != nil {
- <-trickSem
- return
- }
- // Full-decode pass (samples 1 frame per interval over the whole
- // file) — generous deadline like subtitles; idempotent + cached.
- // INVARIANT: this deadline MUST stay below mediainfo.trickplayLockTTL,
- // or another agent could reclaim a still-running job's lock and double
- // the decode. If you raise this, raise trickplayLockTTL too.
- jctx, cancel := context.WithTimeout(ctx, 45*time.Minute)
- _, err := mediainfo.GenerateTrickplay(jctx, opts.FFmpegPath, j.path, opts.TrickplayIntervalSec, j.width, j.duration)
- cancel()
- <-trickSem
- mu.Lock()
- switch {
- case err == nil:
- trickCached++
- case errors.Is(err, mediainfo.ErrTrickplayInProgress):
- // another worker/agent owns this file — skip, not a failure.
- default:
- failed++
- if sampleErr == "" {
- sampleErr = err.Error()
- }
- }
- mu.Unlock()
- continue
- }
-
- // Extract only the indices not already fresh, and do them in ONE
- // ffmpeg pass — a multi-GB remux is demuxed once for all its text
- // tracks instead of once per track.
- todo := make([]int, 0, len(j.subIdx))
- for _, idx := range j.subIdx {
- if _, ok := mediainfo.ReadCachedSubtitle(j.path, idx); !ok {
- todo = append(todo, idx)
- }
- }
- if len(todo) == 0 {
- continue
- }
- // Generous per-file deadline. Subtitle packets are interleaved across
- // the whole container, so extraction is I/O-bound: it must read the
- // entire file once (all text tracks share that single pass). A 60GB
- // remux over ~75 MB/s NFS is ~14 min, so 45 min covers files up to
- // ~200GB; bounded so one corrupt/stalled file can't wedge a worker.
- // This is background + idempotent — it only runs until the cache fills.
- jctx, cancel := context.WithTimeout(ctx, 45*time.Minute)
- res, err := mediainfo.ExtractSubtitlesVTTMulti(jctx, opts.FFmpegPath, j.path, todo)
- cancel()
- if err != nil {
- mu.Lock()
- failed += len(todo)
- if sampleErr == "" {
- sampleErr = err.Error()
- }
- mu.Unlock()
- continue
- }
- for idx, vtt := range res {
- if werr := mediainfo.WriteCachedSubtitle(j.path, idx, vtt); werr != nil {
- log.Printf("[prewarm] sidecar write skipped (i=%d path=%q): %v", idx, j.path, werr)
- mu.Lock()
- failed++
- mu.Unlock()
- continue
- }
- mu.Lock()
- subCached++
- mu.Unlock()
- }
- }
- }()
- }
-
- go func() {
- defer close(jobs)
- for _, item := range cache.Items {
- if item.MediaInfo == nil || item.FilePath == "" {
- continue
- }
- if opts.CacheSubtitles {
- var subIdx []int
- for idx, sub := range item.MediaInfo.Subtitles {
- if mediainfo.IsTextSubtitleCodec(sub.Codec) {
- subIdx = append(subIdx, idx) // bitmap → burned in, skipped
- }
- }
- if len(subIdx) > 0 {
- select {
- case jobs <- prewarmJob{path: item.FilePath, subIdx: subIdx}:
- case <-ctx.Done():
- return
- }
- }
- }
- if opts.CacheThumbnails {
- for _, pos := range thumbPositions(item) {
- select {
- case jobs <- prewarmJob{path: item.FilePath, thumb: true, posSec: pos, width: thumbWidth}:
- case <-ctx.Done():
- return
- }
- }
- }
- if opts.Trickplay && opts.TrickplayIntervalSec > 0 {
- dur := 0.0
- if item.MediaInfo.Video != nil {
- dur = item.MediaInfo.Video.Duration
- }
- if dur > 0 {
- w := opts.TrickplayWidth
- if w <= 0 {
- w = 240
- }
- select {
- case jobs <- prewarmJob{path: item.FilePath, trick: true, width: w, duration: dur}:
- case <-ctx.Done():
- return
- }
- }
- }
- }
- }()
-
- wg.Wait()
- if subCached > 0 || thumbCached > 0 || trickCached > 0 || failed > 0 {
- if failed > 0 && sampleErr != "" {
- log.Printf("[prewarm] %d subtitles, %d thumbnails, %d trickplay cached, %d failed (e.g. %s)", subCached, thumbCached, trickCached, failed, sampleErr)
- } else {
- log.Printf("[prewarm] %d subtitles, %d thumbnails, %d trickplay cached, %d failed", subCached, thumbCached, trickCached, failed)
- }
- }
-}
-
-// prewarmLoadWaitCap bounds how long the load gate DEFERS a trickplay job. It's a
-// throttle, not an off-switch: on a host whose baseline load is permanently above
-// the threshold (a shared prod box, or any 1–2 core machine), an unbounded wait
-// would mean sprites NEVER generate. After the cap we proceed anyway — the other
-// safeguards (single-flight lock, trickSem=1, nice 19 + idle I/O, Pdeathsig) keep
-// one throttled decode from saturating the box.
-const prewarmLoadWaitCap = 15 * time.Minute
-
-// waitForLowLoad defers until the 1-minute system load is at or below
-// max(maxRatio×NumCPU, 1.5), or ctx is cancelled, or prewarmLoadWaitCap elapses —
-// so the heavy trickplay decode prefers an idle machine but never stalls forever.
-// The 1.5 floor reserves ~one core so the gate can still open on 1–2 core hosts
-// (without it, threshold 0.7–1.4 is below almost any active machine's load and the
-// feature would be permanently off). No load reading (non-Linux) → returns at once.
-func waitForLowLoad(ctx context.Context, maxRatio float64) {
- threshold := maxRatio * float64(runtime.NumCPU())
- if threshold < 1.5 {
- threshold = 1.5
- }
- deadline := time.After(prewarmLoadWaitCap)
- logged := false
- for {
- load, ok := mediainfo.LoadAverage1()
- if !ok || load <= threshold {
- return
- }
- if !logged {
- log.Printf("[prewarm] system load %.1f > %.1f — deferring trickplay (≤ %s)", load, threshold, prewarmLoadWaitCap)
- logged = true
- }
- select {
- case <-ctx.Done():
- return
- case <-deadline:
- log.Printf("[prewarm] load still high after %s — proceeding with throttled trickplay (nice + idle I/O + single-flight still apply)", prewarmLoadWaitCap)
- return
- case <-time.After(15 * time.Second):
- }
- }
-}
-
-// thumbPositions returns the sample frame offsets (whole seconds) for an item,
-// matching the web panel: fractions of a known runtime, else fixed fallbacks.
-func thumbPositions(item LibraryItem) []float64 {
- var dur float64
- if item.MediaInfo != nil && item.MediaInfo.Video != nil {
- dur = item.MediaInfo.Video.Duration
- }
- src := thumbFallbackSec
- if dur > 0 {
- src = make([]float64, len(thumbFractions))
- for i, f := range thumbFractions {
- src[i] = math.Round(dur * f)
- }
- }
- // Dedup (short clips can round multiple fractions to the same second).
- seen := make(map[float64]struct{}, len(src))
- out := make([]float64, 0, len(src))
- for _, p := range src {
- if _, ok := seen[p]; ok {
- continue
- }
- seen[p] = struct{}{}
- out = append(out, p)
- }
- return out
-}
diff --git a/internal/library/prewarm_test.go b/internal/library/prewarm_test.go
deleted file mode 100644
index e1dd0be..0000000
--- a/internal/library/prewarm_test.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package library
-
-import (
- "reflect"
- "testing"
-
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
-)
-
-func itemWithDuration(d float64) LibraryItem {
- return LibraryItem{
- FilePath: "/m/x.mkv",
- MediaInfo: &mediainfo.MediaInfo{Video: &mediainfo.VideoInfo{Duration: d}},
- }
-}
-
-func TestThumbPositions(t *testing.T) {
- // Known duration → fractions (0.1/0.3/0.5/0.7/0.9) rounded to whole seconds.
- if got := thumbPositions(itemWithDuration(1000)); !reflect.DeepEqual(got, []float64{100, 300, 500, 700, 900}) {
- t.Errorf("dur=1000 → %v, want [100 300 500 700 900]", got)
- }
-
- // Unknown duration (no video info) → fixed fallback offsets.
- if got := thumbPositions(itemWithDuration(0)); !reflect.DeepEqual(got, []float64{30, 120, 300, 600, 1200}) {
- t.Errorf("dur=0 → %v, want fallback", got)
- }
- if got := thumbPositions(LibraryItem{FilePath: "/m/x.mkv"}); !reflect.DeepEqual(got, []float64{30, 120, 300, 600, 1200}) {
- t.Errorf("nil MediaInfo → %v, want fallback", got)
- }
-
- // Very short clip → multiple fractions round to the same second; deduped.
- // dur=2: round(0.2,0.6,1.0,1.4,1.8) = 0,1,1,1,2 → [0 1 2].
- if got := thumbPositions(itemWithDuration(2)); !reflect.DeepEqual(got, []float64{0, 1, 2}) {
- t.Errorf("dur=2 → %v, want [0 1 2] (deduped)", got)
- }
-}
diff --git a/internal/library/resolve.go b/internal/library/resolve.go
index b9c16db..531fa3b 100644
--- a/internal/library/resolve.go
+++ b/internal/library/resolve.go
@@ -13,17 +13,8 @@ var (
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
)
-// ResolveResolution maps video dimensions to a standard resolution label.
-// Uses both width and height so cinematic aspect ratios (2.35:1, 2.39:1, 21:9)
-// are not misclassified — e.g. a 1080p source presented as 1920×804 letterboxed
-// would fall to 720p if classified by height alone.
-func ResolveResolution(width, height int) string {
- byHeight := resolutionByHeight(height)
- byWidth := resolutionByWidth(width)
- return maxResolution(byHeight, byWidth)
-}
-
-func resolutionByHeight(height int) string {
+// ResolveResolution maps a pixel height to a standard resolution label.
+func ResolveResolution(height int) string {
switch {
case height >= 2000:
return "2160p"
@@ -38,36 +29,6 @@ func resolutionByHeight(height int) string {
}
}
-func resolutionByWidth(width int) string {
- switch {
- case width >= 3400:
- return "2160p"
- case width >= 1800:
- return "1080p"
- case width >= 1200:
- return "720p"
- case width >= 800:
- return "480p"
- default:
- return ""
- }
-}
-
-var resolutionRank = map[string]int{
- "": 0,
- "480p": 1,
- "720p": 2,
- "1080p": 3,
- "2160p": 4,
-}
-
-func maxResolution(a, b string) string {
- if resolutionRank[a] >= resolutionRank[b] {
- return a
- }
- return b
-}
-
// DeriveContentType guesses "movie" or "show" from parsed metadata.
func DeriveContentType(item LibraryItem) string {
if item.Season > 0 || item.Episode > 0 {
diff --git a/internal/library/resolve_test.go b/internal/library/resolve_test.go
index 881768e..c226e06 100644
--- a/internal/library/resolve_test.go
+++ b/internal/library/resolve_test.go
@@ -8,31 +8,28 @@ import (
func TestResolveResolution(t *testing.T) {
tests := []struct {
- name string
- width int
height int
want string
}{
- {"4K square", 3840, 2160, "2160p"},
- {"4K low height", 3840, 1600, "2160p"},
- {"1080p square", 1920, 1080, "1080p"},
- {"1080p cinematic 2.39:1", 1920, 804, "1080p"}, // anamorphic widescreen — must not fall to 720p
- {"1080p cinematic 2.35:1", 1920, 818, "1080p"},
- {"1080p 21:9", 2560, 1080, "1080p"},
- {"720p square", 1280, 720, "720p"},
- {"720p widescreen", 1280, 540, "720p"},
- {"480p", 854, 480, "480p"},
- {"sub-480", 640, 360, ""},
- {"zero", 0, 0, ""},
+ {2160, "2160p"},
+ {2000, "2160p"},
+ {1080, "1080p"},
+ {1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080
+ {900, "1080p"},
+ {720, "720p"},
+ {600, "720p"},
+ {576, "480p"},
+ {480, "480p"},
+ {400, "480p"},
+ {360, ""},
+ {0, ""},
}
for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got := ResolveResolution(tt.width, tt.height)
- if got != tt.want {
- t.Errorf("ResolveResolution(%d, %d) = %q, want %q", tt.width, tt.height, got, tt.want)
- }
- })
+ got := ResolveResolution(tt.height)
+ if got != tt.want {
+ t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want)
+ }
}
}
diff --git a/internal/library/scanner.go b/internal/library/scanner.go
index 7feb746..9b9692e 100644
--- a/internal/library/scanner.go
+++ b/internal/library/scanner.go
@@ -130,26 +130,6 @@ func scanSingleFile(ctx context.Context, ffprobePath, filePath string, cacheIdx
ModTime: info.ModTime().UTC().Format(time.RFC3339),
}
- // Look up the cached entry once — reused for both fingerprint reuse and the
- // incremental ffprobe skip below.
- var cached *LibraryItem
- if existing != nil {
- if idx, ok := cacheIdx[filePath]; ok {
- cached = &existing.Items[idx]
- }
- }
- unchanged := cached != nil &&
- cached.FileSize == item.FileSize && cached.ModTime == item.ModTime
-
- // Fingerprint: reuse the cached value when the file is unchanged and already
- // has one; otherwise compute it (cheap, two bounded reads). Computed even on
- // the incremental path so every synced item carries a stable identity.
- if unchanged && cached.Fingerprint != "" {
- item.Fingerprint = cached.Fingerprint
- } else if fp, fpErr := ComputeFingerprint(filePath, item.FileSize); fpErr == nil {
- item.Fingerprint = fp
- }
-
// Parse filename for title, year, quality, codec
parsed := parser.Parse(item.FileName)
item.Quality = parsed.Quality
@@ -165,15 +145,15 @@ func scanSingleFile(ctx context.Context, ffprobePath, filePath string, cacheIdx
// Parse season/episode
item.Season, item.Episode = ParseSeasonEpisode(item.FileName)
- // Incremental: skip if file hasn't changed. EXCEPT a previously-damaged
- // file is always re-probed — a re-download to the same path can land with
- // an identical size+mtime (some torrent clients preserve the torrent's
- // mtime), so trusting the cached "damaged" verdict would pin a now-healthy
- // file as broken forever. Re-probing damaged items is cheap (they're few).
- if incremental && unchanged &&
- cached.MediaInfo != nil && cached.MediaInfo.Integrity == nil {
- item.MediaInfo = cached.MediaInfo
- return item
+ // Incremental: skip if file hasn't changed
+ if incremental && existing != nil {
+ if idx, ok := cacheIdx[filePath]; ok {
+ cached := existing.Items[idx]
+ if cached.FileSize == item.FileSize && cached.ModTime == item.ModTime && cached.MediaInfo != nil {
+ item.MediaInfo = cached.MediaInfo
+ return item
+ }
+ }
}
// Run ffprobe
diff --git a/internal/library/skipdetect.go b/internal/library/skipdetect.go
deleted file mode 100644
index 03d5048..0000000
--- a/internal/library/skipdetect.go
+++ /dev/null
@@ -1,420 +0,0 @@
-package library
-
-import (
- "context"
- "log"
- "math"
- "path/filepath"
- "regexp"
- "sort"
- "strconv"
- "strings"
- "sync"
- "time"
-
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
-)
-
-// Skip-segment detection: find intro (OP) and credits (ED) ranges by comparing
-// chromaprint audio fingerprints between episodes of the same season (episodes
-// share identical intro/credits audio), plus black-frame credits detection for
-// movies (no sibling to compare). Results are cached as ".unarr" sidecars and
-// submitted to the web, which shares them across all users by content identity.
-
-const (
- skipMinIntroSec = 15
- skipMaxIntroSec = 120
- skipMinCreditsSec = 15
- skipMaxCreditsSec = 450
- skipCreditsWindow = 450 // episodes: fingerprint the last N seconds
- skipIntroWindowCap = 600 // episodes: fingerprint at most the first N seconds
- skipMinRuntimeSec = 300 // ignore shorts/extras
-
- movieCreditsWindow = 900 // movies: black-frame scan over the last N seconds
- movieMinCreditsSec = 60
- movieMinRuntimeSec = 3600
-)
-
-// SkipDetectOptions configures DetectSkipSegments.
-type SkipDetectOptions struct {
- FFmpegPath string
- FpcalcPath string // empty disables episode (chromaprint) detection
- Workers int // concurrent ffmpeg+fpcalc jobs; default 2
- Movies bool // also detect movie end credits via black frames
-}
-
-// SkipDetection is the outcome for one media file (only files with ≥1 segment
-// are returned).
-type SkipDetection struct {
- Item LibraryItem
- DurationSec float64
- Segments []mediainfo.SkipSegmentRange
-}
-
-// DetectSkipSegments analyzes the scanned library and returns every file with
-// detected skippable segments. Idempotent and best-effort: fresh sidecar
-// results are reused without re-analysis, errors skip the file, ctx cancels
-// cleanly.
-func DetectSkipSegments(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions) []SkipDetection {
- if cache == nil || opts.FFmpegPath == "" {
- return nil
- }
- workers := opts.Workers
- if workers < 1 {
- workers = 2
- }
-
- var out []SkipDetection
- var outMu sync.Mutex
- add := func(item LibraryItem, dur float64, segs []mediainfo.SkipSegmentRange) {
- if len(segs) == 0 {
- return
- }
- outMu.Lock()
- out = append(out, SkipDetection{Item: item, DurationSec: dur, Segments: segs})
- outMu.Unlock()
- }
-
- start := time.Now()
- analyzed, cached := 0, 0
-
- if opts.FpcalcPath != "" {
- a, c := detectEpisodeGroups(ctx, cache, opts, workers, add)
- analyzed += a
- cached += c
- }
- if opts.Movies {
- a, c := detectMovieCredits(ctx, cache, opts, workers, add)
- analyzed += a
- cached += c
- }
-
- log.Printf("[skipdetect] %d file(s) analyzed (%d from cache), %d with segments, in %s",
- analyzed, cached, len(out), time.Since(start).Round(time.Second))
- return out
-}
-
-// seasonEpisodeMarker locates the SxxEyy token in a parsed title so the group
-// key uses only the SHOW part. Parsed titles keep the episode name + release
-// tags ("Show S01E09 Embrace and Whisper BILI WEB DL…"), which differ per
-// file — grouping on the raw title would leave every episode alone.
-var seasonEpisodeMarker = regexp.MustCompile(`(?i)\bS\d{1,2}\s*E\d{1,4}\b`)
-
-// seasonGroupKey groups episodes that can share intro/credits audio: same
-// directory + same show-title prefix + same season. The directory bound keeps
-// flat mixed folders from exploding into one giant group; cross-show pairs
-// inside a dir fail closed anyway (unrelated audio never matches).
-func seasonGroupKey(item LibraryItem) string {
- title := strings.ToLower(strings.TrimSpace(item.Title))
- if loc := seasonEpisodeMarker.FindStringIndex(title); loc != nil {
- title = strings.TrimSpace(title[:loc[0]])
- }
- return filepath.Dir(item.FilePath) + "|" + title + "|s" + strconv.Itoa(item.Season)
-}
-
-func itemDuration(item LibraryItem) float64 {
- if item.MediaInfo != nil && item.MediaInfo.Video != nil {
- return item.MediaInfo.Video.Duration
- }
- return 0
-}
-
-// detectEpisodeGroups runs chromaprint comparison inside (title, season)
-// groups. Returns (analyzed, fromCache) counters.
-func detectEpisodeGroups(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions, workers int, add func(LibraryItem, float64, []mediainfo.SkipSegmentRange)) (int, int) {
- groups := make(map[string][]LibraryItem)
- for _, item := range cache.Items {
- if item.Season <= 0 || item.Episode <= 0 || item.FilePath == "" {
- continue
- }
- if itemDuration(item) < skipMinRuntimeSec {
- continue
- }
- groups[seasonGroupKey(item)] = append(groups[seasonGroupKey(item)], item)
- }
-
- analyzed, fromCache := 0, 0
- for _, items := range groups {
- if ctx.Err() != nil {
- break
- }
- // Distinct episode numbers — two releases of the same episode carry
- // identical full audio (a comparison would match the whole window).
- eps := make(map[int]struct{})
- for _, it := range items {
- eps[it.Episode] = struct{}{}
- }
- if len(eps) < 2 {
- continue
- }
-
- // Cached results short-circuit the whole group when complete.
- needCompute := false
- cachedSegs := make(map[string]*mediainfo.SkipSegmentsSidecar, len(items))
- for _, it := range items {
- if sc, ok := mediainfo.ReadCachedSkipSegments(it.FilePath); ok {
- cachedSegs[it.FilePath] = sc
- } else {
- needCompute = true
- }
- }
- if !needCompute {
- for _, it := range items {
- sc := cachedSegs[it.FilePath]
- analyzed++
- fromCache++
- add(it, sc.DurationSec, sc.Segments)
- }
- continue
- }
-
- // Fingerprint every episode in the group (intro + credits windows).
- fps := fingerprintGroup(ctx, items, opts, workers)
-
- sort.Slice(items, func(i, j int) bool { return items[i].Episode < items[j].Episode })
- for _, it := range items {
- if ctx.Err() != nil {
- break
- }
- analyzed++
- if sc, ok := cachedSegs[it.FilePath]; ok {
- fromCache++
- add(it, sc.DurationSec, sc.Segments)
- continue
- }
- fp := fps[it.FilePath]
- if fp == nil {
- continue
- }
- segs := detectForEpisode(it, fp, items, fps)
- if err := mediainfo.WriteCachedSkipSegments(it.FilePath, fp.duration, segs); err != nil {
- log.Printf("[skipdetect] sidecar write skipped (%q): %v", it.FilePath, err)
- }
- add(it, fp.duration, segs)
- }
- }
- return analyzed, fromCache
-}
-
-// episodeFingerprints holds the two fingerprinted windows of one file.
-type episodeFingerprints struct {
- duration float64
- intro []uint32
- credits []uint32
- creditsStart float64 // absolute offset of the credits window
-}
-
-func fingerprintGroup(ctx context.Context, items []LibraryItem, opts SkipDetectOptions, workers int) map[string]*episodeFingerprints {
- fps := make(map[string]*episodeFingerprints, len(items))
- var mu sync.Mutex
- jobs := make(chan LibraryItem)
- var wg sync.WaitGroup
- for i := 0; i < workers; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- for it := range jobs {
- if ctx.Err() != nil {
- return
- }
- dur := itemDuration(it)
- introWin := math.Min(0.25*dur, skipIntroWindowCap)
- credStart := math.Max(0, dur-skipCreditsWindow)
- jctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
- intro, err1 := mediainfo.FingerprintAudioWindow(jctx, opts.FFmpegPath, opts.FpcalcPath, it.FilePath, 0, introWin)
- credits, err2 := mediainfo.FingerprintAudioWindow(jctx, opts.FFmpegPath, opts.FpcalcPath, it.FilePath, credStart, skipCreditsWindow)
- cancel()
- if err1 != nil || err2 != nil {
- if err1 != nil {
- log.Printf("[skipdetect] fingerprint failed (%q): %v", it.FilePath, err1)
- } else {
- log.Printf("[skipdetect] fingerprint failed (%q): %v", it.FilePath, err2)
- }
- continue
- }
- mu.Lock()
- fps[it.FilePath] = &episodeFingerprints{duration: dur, intro: intro, credits: credits, creditsStart: credStart}
- mu.Unlock()
- }
- }()
- }
- for _, it := range items {
- // Skip already-cached files only if every OTHER episode can still find
- // partners — fingerprinting cached files too keeps them available as
- // comparison partners for the new ones, so always fingerprint.
- select {
- case jobs <- it:
- case <-ctx.Done():
- }
- if ctx.Err() != nil {
- break
- }
- }
- close(jobs)
- wg.Wait()
- return fps
-}
-
-// detectForEpisode compares one episode against partners (nearest different
-// episode numbers first, up to 3) and returns its detected segments.
-func detectForEpisode(it LibraryItem, fp *episodeFingerprints, items []LibraryItem, fps map[string]*episodeFingerprints) []mediainfo.SkipSegmentRange {
- type partner struct {
- fp *episodeFingerprints
- dist int
- }
- var partners []partner
- for _, other := range items {
- if other.FilePath == it.FilePath || other.Episode == it.Episode {
- continue
- }
- ofp := fps[other.FilePath]
- if ofp == nil {
- continue
- }
- d := other.Episode - it.Episode
- if d < 0 {
- d = -d
- }
- partners = append(partners, partner{fp: ofp, dist: d})
- }
- sort.Slice(partners, func(i, j int) bool { return partners[i].dist < partners[j].dist })
- if len(partners) > 3 {
- partners = partners[:3]
- }
-
- segs := make([]mediainfo.SkipSegmentRange, 0, 2)
-
- for _, p := range partners {
- r := mediainfo.FindSharedRegion(fp.intro, p.fp.intro, skipMinIntroSec, skipMaxIntroSec)
- if r == nil {
- continue
- }
- start, end := r.AStart, r.AEnd
- if start <= 5 { // OP at the head — snap to the very start
- start = 0
- }
- segs = append(segs, mediainfo.SkipSegmentRange{Category: "intro", StartSec: round1(start), EndSec: round1(end)})
- break
- }
-
- for _, p := range partners {
- // A near-full-window match means the two files share ALL audio (same
- // episode content) — not a credits segment.
- r := mediainfo.FindSharedRegion(fp.credits, p.fp.credits, skipMinCreditsSec, skipMaxCreditsSec)
- if r == nil || r.Duration >= 0.97*skipCreditsWindow {
- continue
- }
- segs = append(segs, mediainfo.SkipSegmentRange{
- Category: "credits",
- StartSec: round1(fp.creditsStart + r.AStart),
- EndSec: round1(fp.creditsStart + r.AEnd),
- })
- break
- }
- return segs
-}
-
-// detectMovieCredits finds end-credits in movies via sustained black-frame
-// runs (classic credits roll on black). Single-file, no fingerprinting.
-func detectMovieCredits(ctx context.Context, cache *LibraryCache, opts SkipDetectOptions, workers int, add func(LibraryItem, float64, []mediainfo.SkipSegmentRange)) (int, int) {
- var movies []LibraryItem
- for _, item := range cache.Items {
- if item.Season > 0 || item.Episode > 0 || item.FilePath == "" {
- continue
- }
- if itemDuration(item) < movieMinRuntimeSec {
- continue
- }
- movies = append(movies, item)
- }
-
- analyzed, fromCache := 0, 0
- var mu sync.Mutex
- jobs := make(chan LibraryItem)
- var wg sync.WaitGroup
- for i := 0; i < workers; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- for it := range jobs {
- if ctx.Err() != nil {
- return
- }
- dur := itemDuration(it)
- if sc, ok := mediainfo.ReadCachedSkipSegments(it.FilePath); ok {
- mu.Lock()
- analyzed++
- fromCache++
- mu.Unlock()
- add(it, sc.DurationSec, sc.Segments)
- continue
- }
- winStart := math.Max(0, dur-movieCreditsWindow)
- jctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
- times, err := mediainfo.DetectBlackFrameRuns(jctx, opts.FFmpegPath, it.FilePath, winStart, movieCreditsWindow, 85)
- cancel()
- if err != nil {
- log.Printf("[skipdetect] blackframe failed (%q): %v", it.FilePath, err)
- continue
- }
- segs := creditsFromBlackRuns(times, dur)
- if werr := mediainfo.WriteCachedSkipSegments(it.FilePath, dur, segs); werr != nil {
- log.Printf("[skipdetect] sidecar write skipped (%q): %v", it.FilePath, werr)
- }
- mu.Lock()
- analyzed++
- mu.Unlock()
- add(it, dur, segs)
- }
- }()
- }
- for _, it := range movies {
- select {
- case jobs <- it:
- case <-ctx.Done():
- }
- if ctx.Err() != nil {
- break
- }
- }
- close(jobs)
- wg.Wait()
- return analyzed, fromCache
-}
-
-// creditsFromBlackRuns picks the credits start from black-frame timestamps:
-// the longest run of black frames (gaps ≤30s between hits) that reaches the
-// end of the file (within 90s — post-credits scenes break the run and are
-// kept watchable). Requires ≥60s of credits to avoid fade-to-black scenes.
-func creditsFromBlackRuns(times []float64, durationSec float64) []mediainfo.SkipSegmentRange {
- if len(times) == 0 {
- return nil
- }
- const maxGap = 30.0
- bestStart, bestEnd := -1.0, -1.0
- runStart := times[0]
- prev := times[0]
- flush := func(end float64) {
- if end-runStart > bestEnd-bestStart {
- bestStart, bestEnd = runStart, end
- }
- }
- for _, t := range times[1:] {
- if t-prev > maxGap {
- flush(prev)
- runStart = t
- }
- prev = t
- }
- flush(prev)
-
- if bestStart < 0 || bestEnd-bestStart < movieMinCreditsSec {
- return nil
- }
- if durationSec-bestEnd > 90 { // run doesn't reach the end → mid-film scene
- return nil
- }
- return []mediainfo.SkipSegmentRange{{Category: "credits", StartSec: round1(bestStart), EndSec: round1(durationSec)}}
-}
-
-func round1(v float64) float64 { return math.Round(v*10) / 10 }
diff --git a/internal/library/skipdetect_test.go b/internal/library/skipdetect_test.go
deleted file mode 100644
index 8df7229..0000000
--- a/internal/library/skipdetect_test.go
+++ /dev/null
@@ -1,70 +0,0 @@
-package library
-
-import (
- "testing"
-
- "github.com/torrentclaw/unarr/internal/library/mediainfo"
-)
-
-func TestCreditsFromBlackRuns_DetectsCreditsRoll(t *testing.T) {
- // Movie of 7200s; credits-on-black from 6900 to the end, black frame every
- // ~2s, plus a stray fade-to-black at 5000s that must not win.
- var times []float64
- times = append(times, 5000, 5001) // short mid-film fade
- for tt := 6900.0; tt <= 7195; tt += 2 {
- times = append(times, tt)
- }
- segs := creditsFromBlackRuns(times, 7200)
- if len(segs) != 1 {
- t.Fatalf("expected 1 credits segment, got %d", len(segs))
- }
- s := segs[0]
- if s.Category != "credits" {
- t.Errorf("category = %q, want credits", s.Category)
- }
- if s.StartSec < 6890 || s.StartSec > 6910 {
- t.Errorf("StartSec = %.1f, want ≈ 6900", s.StartSec)
- }
- if s.EndSec != 7200 {
- t.Errorf("EndSec = %.1f, want 7200 (file end)", s.EndSec)
- }
-}
-
-func TestCreditsFromBlackRuns_RejectsShortFade(t *testing.T) {
- // Only a 20s black run near the end — too short to be credits.
- var times []float64
- for tt := 7170.0; tt <= 7190; tt += 2 {
- times = append(times, tt)
- }
- if segs := creditsFromBlackRuns(times, 7200); len(segs) != 0 {
- t.Fatalf("expected no segments for a 20s fade, got %+v", segs)
- }
-}
-
-func TestCreditsFromBlackRuns_RejectsRunNotReachingEnd(t *testing.T) {
- // 120s black run that ends 300s before the file end (a long mid-film
- // montage on black) — must not be flagged as credits.
- var times []float64
- for tt := 6700.0; tt <= 6820; tt += 2 {
- times = append(times, tt)
- }
- if segs := creditsFromBlackRuns(times, 7200); len(segs) != 0 {
- t.Fatalf("expected no segments when run stops mid-film, got %+v", segs)
- }
-}
-
-func TestDetectForEpisode_PrefersDifferentEpisodePartners(t *testing.T) {
- // Sanity: an episode with no partners (all same episode number) yields nil.
- it := LibraryItem{FilePath: "/a/e1.mkv", Episode: 1, Season: 1}
- dup := LibraryItem{FilePath: "/a/e1-other-release.mkv", Episode: 1, Season: 1}
- fps := map[string]*episodeFingerprints{
- it.FilePath: {duration: 1400, intro: []uint32{1, 2, 3}, credits: []uint32{4, 5, 6}},
- dup.FilePath: {duration: 1400, intro: []uint32{1, 2, 3}, credits: []uint32{4, 5, 6}},
- }
- segs := detectForEpisode(it, fps[it.FilePath], []LibraryItem{it, dup}, fps)
- if len(segs) != 0 {
- t.Fatalf("expected no segments without a different-episode partner, got %+v", segs)
- }
-}
-
-var _ = mediainfo.SkipSegmentRange{} // keep import for future use
diff --git a/internal/library/subtitles.go b/internal/library/subtitles.go
deleted file mode 100644
index 0b18b1b..0000000
--- a/internal/library/subtitles.go
+++ /dev/null
@@ -1,142 +0,0 @@
-package library
-
-import (
- "fmt"
- "io"
- "log"
- "net/http"
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "time"
-
- "github.com/torrentclaw/unarr/internal/agent"
-)
-
-// maxSubtitleBytes caps a downloaded subtitle (sane: even a long film SRT is
-// a few hundred KB; this guards against a misbehaving upstream).
-const maxSubtitleBytes = 10 << 20 // 10 MiB
-
-var subtitleLangRe = regexp.MustCompile(`^[a-z]{2,3}$`)
-
-var subtitleHTTPClient = &http.Client{Timeout: 30 * time.Second}
-
-// FetchSubtitles downloads each requested subtitle (from our proxy URL, already
-// charset-fixed WebVTT) and writes it as a sidecar next to the media file:
-// `..vtt`. Returns the IDs successfully written (or already
-// present) and the ones that failed (with a short reason) so the web can mark
-// them errored. Safety mirrors DeleteFiles: the media file must resolve within a
-// configured scan path before we write beside it.
-func FetchSubtitles(reqs []agent.SubtitleFetchRequest, scanPaths []string) (done []int, failed []agent.SubtitleFetchError) {
- // Resolve scan paths through symlinks too, so a symlinked root (e.g. the
- // docker bind-mount /downloads → /mnt/nas/peliculas) still matches a media
- // path that EvalSymlinks resolved to the real target. Mirrors the containment
- // check used for the resolved media path below.
- safe := make([]string, 0, len(scanPaths))
- for _, sp := range scanPaths {
- if !filepath.IsAbs(sp) {
- log.Printf("library: ignoring non-absolute scan path: %q", sp)
- continue
- }
- if real, err := filepath.EvalSymlinks(sp); err == nil {
- safe = append(safe, real)
- } else {
- safe = append(safe, filepath.Clean(sp))
- }
- }
- if len(safe) == 0 {
- log.Printf("library: no valid scan paths — refusing to write subtitle sidecars")
- for _, r := range reqs {
- failed = append(failed, agent.SubtitleFetchError{ID: r.ID, Error: "no valid scan paths"})
- }
- return nil, failed
- }
-
- for _, r := range reqs {
- if err := fetchSubtitleOne(r, safe); err != nil {
- log.Printf("library: subtitle fetch %d (%q): %v", r.ID, r.FilePath, err)
- msg := err.Error()
- if len(msg) > 480 {
- msg = msg[:480]
- }
- failed = append(failed, agent.SubtitleFetchError{ID: r.ID, Error: msg})
- continue
- }
- log.Printf("library: wrote subtitle sidecar for item %d (%s)", r.ID, r.Lang)
- done = append(done, r.ID)
- }
- return done, failed
-}
-
-func fetchSubtitleOne(r agent.SubtitleFetchRequest, scanPaths []string) error {
- if !filepath.IsAbs(r.FilePath) {
- return fmt.Errorf("path is not absolute: %q", r.FilePath)
- }
- lang := strings.ToLower(strings.TrimSpace(r.Lang))
- if !subtitleLangRe.MatchString(lang) {
- return fmt.Errorf("invalid language %q", r.Lang)
- }
-
- // Resolve the media file (symlinks too) and confine it to a scan path.
- real, err := filepath.EvalSymlinks(filepath.Clean(r.FilePath))
- if err != nil {
- return fmt.Errorf("media file unreachable: %w", err)
- }
- if !isWithinScanPaths(real, scanPaths) {
- return fmt.Errorf("path %q is outside all scan paths", real)
- }
-
- ext := filepath.Ext(real)
- sidecar := strings.TrimSuffix(real, ext) + "." + lang + ".vtt"
- if _, statErr := os.Stat(sidecar); statErr == nil {
- return nil // already present — idempotent success
- }
-
- data, err := downloadSubtitle(r.URL)
- if err != nil {
- return err
- }
-
- // Write atomically: temp in the same dir, then rename. Clean up any stale
- // .tmp from a prior crash first, and on every failure path, so a partial
- // write (disk full, killed) never lingers.
- tmp := sidecar + ".tmp"
- _ = os.Remove(tmp)
- if err := os.WriteFile(tmp, data, 0o644); err != nil {
- _ = os.Remove(tmp)
- return fmt.Errorf("write temp sidecar: %w", err)
- }
- if err := os.Rename(tmp, sidecar); err != nil {
- _ = os.Remove(tmp)
- return fmt.Errorf("rename sidecar: %w", err)
- }
- return nil
-}
-
-func downloadSubtitle(url string) ([]byte, error) {
- // Our proxy URL is always HTTPS. Restrict to https (allow http only for a
- // local dev server) so a tampered sync response can't point the agent at an
- // internal/metadata host.
- if !strings.HasPrefix(url, "https://") &&
- !strings.HasPrefix(url, "http://localhost") &&
- !strings.HasPrefix(url, "http://127.0.0.1") {
- return nil, fmt.Errorf("subtitle url must be https")
- }
- resp, err := subtitleHTTPClient.Get(url)
- if err != nil {
- return nil, fmt.Errorf("download: %w", err)
- }
- defer resp.Body.Close()
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("download status %d", resp.StatusCode)
- }
- data, err := io.ReadAll(io.LimitReader(resp.Body, maxSubtitleBytes))
- if err != nil {
- return nil, fmt.Errorf("read body: %w", err)
- }
- if len(data) == 0 {
- return nil, fmt.Errorf("empty subtitle")
- }
- return data, nil
-}
diff --git a/internal/library/sync.go b/internal/library/sync.go
index c2bef14..bafd054 100644
--- a/internal/library/sync.go
+++ b/internal/library/sync.go
@@ -1,87 +1,6 @@
package library
-import (
- "context"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/torrentclaw/unarr/internal/agent"
-)
-
-// SyncOptions describes ONE library sync session — a set of batches sharing a
-// single syncStartedAt so the server can reap rows not seen by the session.
-type SyncOptions struct {
- AgentID string
- // ScanPath is the primary root, kept for pre-scanRoots servers.
- ScanPath string
- // ScanRoots lists every root this session covers (see LibrarySyncRequest).
- ScanRoots []string
- // FullCycle: the session spans every configured root — the server may reap
- // unseen rows regardless of path prefix. NEVER set it for a subtree scan.
- FullCycle bool
- // OnProgress, when non-nil, is called after each batch with (sent, total).
- OnProgress func(sent, total int)
-}
-
-// SyncResult aggregates the per-batch server responses of a session.
-type SyncResult struct {
- Synced int
- Matched int
- Removed int
-}
-
-// SyncBatches uploads items to the server in batches of 100 as ONE sync
-// session: every batch shares the same syncStartedAt and only the final one
-// carries isLastBatch, so the server's stale-row cleanup sees the whole cycle
-// at once. The single source of the batching protocol — shared by `unarr scan`
-// (cmd/scan.go) and the daemon auto-scan (cmd/daemon.go); before this each
-// root synced as its own session and the per-agent cleanup could reap rows of
-// roots the session never visited.
-func SyncBatches(ctx context.Context, ac *agent.Client, items []agent.LibrarySyncItem, opts SyncOptions) (SyncResult, error) {
- const batchSize = 100
- var res SyncResult
- syncStartedAt := time.Now().UTC().Format(time.RFC3339)
- for i := 0; i < len(items); i += batchSize {
- end := i + batchSize
- if end > len(items) {
- end = len(items)
- }
- resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
- Items: items[i:end],
- ScanPath: opts.ScanPath,
- AgentID: opts.AgentID,
- IsLastBatch: end >= len(items),
- SyncStartedAt: syncStartedAt,
- ScanRoots: opts.ScanRoots,
- FullCycle: opts.FullCycle,
- })
- if err != nil {
- return res, err
- }
- res.Synced += resp.Synced
- res.Matched += resp.Matched
- res.Removed += resp.Removed
- if opts.OnProgress != nil {
- opts.OnProgress(end, len(items))
- }
- }
- return res, nil
-}
-
-// relToRoot returns the file's path relative to the scan root (forward-slashed),
-// or "" when it doesn't live under root. The server stores this so streaming can
-// later reconstruct the absolute path from the agent's *current* root.
-func relToRoot(root, full string) string {
- if root == "" {
- return ""
- }
- rel, err := filepath.Rel(root, full)
- if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
- return ""
- }
- return filepath.ToSlash(rel)
-}
+import "github.com/torrentclaw/unarr/internal/agent"
// BuildSyncItems converts cached library items to sync request items.
// Shared between unarr scan (cmd/scan.go) and auto-scan (cmd/daemon.go).
@@ -92,22 +11,19 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem {
continue
}
si := agent.LibrarySyncItem{
- FilePath: item.FilePath,
- FileName: item.FileName,
- FileSize: item.FileSize,
- Title: item.Title,
- Year: item.Year,
- ContentType: DeriveContentType(item),
- Season: item.Season,
- Episode: item.Episode,
- Fingerprint: item.Fingerprint,
- RelPath: relToRoot(cache.Path, item.FilePath),
- LibraryRootKey: "library",
+ FilePath: item.FilePath,
+ FileName: item.FileName,
+ FileSize: item.FileSize,
+ Title: item.Title,
+ Year: item.Year,
+ ContentType: DeriveContentType(item),
+ Season: item.Season,
+ Episode: item.Episode,
}
if item.MediaInfo != nil {
if item.MediaInfo.Video != nil {
- si.Resolution = ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
+ si.Resolution = ResolveResolution(item.MediaInfo.Video.Height)
si.VideoCodec = item.MediaInfo.Video.Codec
si.HDR = item.MediaInfo.Video.HDR
si.BitDepth = item.MediaInfo.Video.BitDepth
@@ -120,10 +36,6 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem {
si.AudioTracks = item.MediaInfo.Audio
si.SubtitleTracks = item.MediaInfo.Subtitles
si.VideoInfo = item.MediaInfo.Video
- if integ := item.MediaInfo.Integrity; integ != nil && integ.Damaged {
- si.Integrity = "damaged"
- si.IntegrityReason = integ.Reason
- }
}
items = append(items, si)
diff --git a/internal/library/types.go b/internal/library/types.go
index 6346461..ca89e8c 100644
--- a/internal/library/types.go
+++ b/internal/library/types.go
@@ -4,21 +4,18 @@ import "github.com/torrentclaw/unarr/internal/library/mediainfo"
// LibraryItem represents a single scanned media file.
type LibraryItem struct {
- FilePath string `json:"filePath"`
- FileName string `json:"fileName"`
- FileSize int64 `json:"fileSize"`
- ModTime string `json:"modTime"` // ISO 8601
- // Fingerprint is a stable content identity (see fingerprint.go). Cached so
- // incremental scans reuse it when size+mtime are unchanged.
- Fingerprint string `json:"fingerprint,omitempty"`
- Title string `json:"title"`
- Year string `json:"year,omitempty"`
- Season int `json:"season,omitempty"`
- Episode int `json:"episode,omitempty"`
- Quality string `json:"quality,omitempty"` // "1080p" etc (from filename)
- Codec string `json:"codec,omitempty"` // "x265" etc (from filename)
- MediaInfo *mediainfo.MediaInfo `json:"mediaInfo,omitempty"`
- ScanError string `json:"scanError,omitempty"`
+ FilePath string `json:"filePath"`
+ FileName string `json:"fileName"`
+ FileSize int64 `json:"fileSize"`
+ ModTime string `json:"modTime"` // ISO 8601
+ Title string `json:"title"`
+ Year string `json:"year,omitempty"`
+ Season int `json:"season,omitempty"`
+ Episode int `json:"episode,omitempty"`
+ Quality string `json:"quality,omitempty"` // "1080p" etc (from filename)
+ Codec string `json:"codec,omitempty"` // "x265" etc (from filename)
+ MediaInfo *mediainfo.MediaInfo `json:"mediaInfo,omitempty"`
+ ScanError string `json:"scanError,omitempty"`
}
// LibraryCache is the on-disk cache of scanned library items.
@@ -29,8 +26,4 @@ type LibraryCache struct {
Items []LibraryItem `json:"items"`
}
-// Bump whenever the scan logic changes in a way that should re-probe an
-// existing library on next scan (incremental reuse keys off mtime+size, so a
-// pure logic change is invisible without this). v2: file-integrity detection
-// (ffprobe corruption / incomplete-download flag).
-const cacheVersion = 2
+const cacheVersion = 1
diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go
index 3f16c08..633fc0d 100644
--- a/internal/sentry/sentry.go
+++ b/internal/sentry/sentry.go
@@ -1,14 +1,12 @@
package sentry
import (
- "errors"
"os"
"runtime"
"strings"
"time"
gosentry "github.com/getsentry/sentry-go"
- "github.com/spf13/pflag"
)
// dsn is injected at build time via ldflags. If empty, Sentry is disabled.
@@ -46,16 +44,9 @@ func Close() {
gosentry.Flush(flushTimeout)
}
-// daemonNotRunningMarker matches the message of agent.ErrDaemonNotRunning
-// without importing the agent package — avoids a sentry → agent dependency
-// that would risk a cycle if agent ever needed to report errors itself.
-const daemonNotRunningMarker = "daemon does not appear to be running"
-
// CaptureError sends a non-fatal error to Sentry with optional command context.
-// Expected non-bug errors (bad CLI input, daemon not running) are skipped to
-// keep the issue feed signal-heavy.
func CaptureError(err error, command string) {
- if err == nil || shouldSkipSentry(err) {
+ if err == nil {
return
}
@@ -67,21 +58,6 @@ func CaptureError(err error, command string) {
})
}
-func shouldSkipSentry(err error) bool {
- var notExist *pflag.NotExistError
- var valueReq *pflag.ValueRequiredError
- var invalidVal *pflag.InvalidValueError
- var invalidSyn *pflag.InvalidSyntaxError
- if errors.As(err, ¬Exist) || errors.As(err, &valueReq) ||
- errors.As(err, &invalidVal) || errors.As(err, &invalidSyn) {
- return true
- }
- msg := err.Error()
- return strings.HasPrefix(msg, "unknown command ") ||
- strings.HasPrefix(msg, "required flag(s)") ||
- strings.Contains(msg, daemonNotRunningMarker)
-}
-
// RecoverPanic captures a panic and re-panics after reporting.
// Usage: defer sentry.RecoverPanic()
func RecoverPanic() {
diff --git a/internal/sentry/sentry_test.go b/internal/sentry/sentry_test.go
index 4005d14..671e641 100644
--- a/internal/sentry/sentry_test.go
+++ b/internal/sentry/sentry_test.go
@@ -1,10 +1,6 @@
package sentry
-import (
- "errors"
- "fmt"
- "testing"
-)
+import "testing"
func TestEnvironment(t *testing.T) {
tests := []struct {
@@ -49,16 +45,3 @@ func TestSetUser(t *testing.T) {
// Should not panic without initialization
SetUser("agent-123")
}
-
-func TestShouldSkipSentryDaemonNotRunning(t *testing.T) {
- // String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel
- // is reworded, this test fails loudly so the marker can be updated.
- err := errors.New("daemon does not appear to be running (state file not found)")
- if !shouldSkipSentry(err) {
- t.Error("ErrDaemonNotRunning message should be skipped")
- }
- wrapped := fmt.Errorf("read daemon state: %w", err)
- if !shouldSkipSentry(wrapped) {
- t.Error("wrapped ErrDaemonNotRunning message should be skipped")
- }
-}
diff --git a/internal/upgrade/signature.go b/internal/upgrade/signature.go
index 2abd0ff..cfcc93d 100644
--- a/internal/upgrade/signature.go
+++ b/internal/upgrade/signature.go
@@ -14,18 +14,17 @@ import (
// releasePubKeyBase64 is the base64-encoded ed25519 public key used to verify
// `checksums.txt.sig` against `checksums.txt` during self-update.
//
-// It is the canonical release-signing public key, compiled in so every build
-// (local ship.sh and CI alike) verifies updates consistently — it is public, so
-// committing it is safe and removes any "forgot to set the env var → shipped an
-// unsigned/unverifying binary" failure mode. The matching PRIVATE key signs
-// checksums.txt during release (scripts/sign-checksums, driven by the
-// goreleaser `signs:` block); releases are signed unconditionally now.
+// It is overridable at link time via ldflags so the same source compiles for
+// users who do not yet have a release-signing keypair in their CI:
//
-// When this is empty, signature verification is skipped (a warning is logged).
-// Do NOT clear it — every release from v1.0.1-beta on ships a checksums.txt.sig
-// and clients built with this key require it. Rotating the key is a coordinated
-// change: clients on the old key must update before the signing key flips.
-var releasePubKeyBase64 = "X7EJVwAiIILs4EGaqp+YBsa4Q6HnKBB2J5FI4MIt+w0="
+// -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64=
+//
+// When the variable is empty, signature verification is skipped and a warning
+// is logged — checksum-only verification remains in force. This is the
+// transitional default until the keypair is provisioned; flip to a non-empty
+// value (and enable the corresponding CI signing step) to make signature
+// verification mandatory.
+var releasePubKeyBase64 = ""
// ErrMissingSignature indicates the release does not ship a `.sig` file even
// though signature verification is required by an embedded public key.
diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go
index 8246a1f..18cde17 100644
--- a/internal/upgrade/upgrade_test.go
+++ b/internal/upgrade/upgrade_test.go
@@ -4,10 +4,7 @@ import (
"archive/tar"
"compress/gzip"
"context"
- "crypto/ed25519"
- "crypto/rand"
"crypto/sha256"
- "encoding/base64"
"encoding/hex"
"fmt"
"net/http"
@@ -676,35 +673,12 @@ func TestDownloadWithHTTPTest(t *testing.T) {
})
}
-// signedChecksumsHandler serves checksums.txt (body) AND a matching
-// checksums.txt.sig (base64 ed25519 signature over the exact body) so a test can
-// drive verifyChecksum's real signature+checksum path with a controlled key.
-func signedChecksumsHandler(body []byte, priv ed25519.PrivateKey) http.HandlerFunc {
- sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, body))
- return func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
- fmt.Fprintln(w, sig)
- return
- }
- _, _ = w.Write(body)
- }
-}
-
func TestVerifyChecksumWithHTTPTest(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("tar.gz test only on unix")
}
- // Drive verifyChecksum's REAL flow: signature verification ENABLED with a test
- // keypair, then the SHA256 match. Each server serves a valid checksums.txt.sig
- // over the exact checksums body so the signature step passes and the
- // checksum-matching assertions are actually reached.
- pub, priv, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatalf("generate keypair: %v", err)
- }
- withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
-
+ // Create a fake archive file
dir := t.TempDir()
archiveContent := []byte("archive-content-for-checksum-test")
archivePath := filepath.Join(dir, "test-archive.tar.gz")
@@ -712,31 +686,43 @@ func TestVerifyChecksumWithHTTPTest(t *testing.T) {
h := sha256.Sum256(archiveContent)
correctHash := hex.EncodeToString(h[:])
- // archiveName() uses runtime.GOOS/GOARCH.
+
+ // The function builds the archive name using archiveName(), which uses runtime.GOOS/GOARCH.
expectedArchiveName := archiveName("1.0.0")
- serve := func(t *testing.T, h http.Handler) {
- srv := httptest.NewServer(h)
- t.Cleanup(srv.Close)
- restore := swapHTTPClient(&http.Client{Transport: &rewriteTransport{url: srv.URL}})
- t.Cleanup(restore)
- }
+ t.Run("matching checksum", func(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "0000000000000000000000000000000000000000000000000000000000000000 other_file.tar.gz\n")
+ fmt.Fprintf(w, "%s %s\n", correctHash, expectedArchiveName)
+ }))
+ defer srv.Close()
- t.Run("matching checksum (valid signature)", func(t *testing.T) {
- body := []byte(fmt.Sprintf("0000000000000000000000000000000000000000000000000000000000000000 other_file.tar.gz\n%s %s\n", correctHash, expectedArchiveName))
- serve(t, signedChecksumsHandler(body, priv))
- if err := verifyChecksum(context.Background(), "1.0.0", archivePath); err != nil {
+ restore := swapHTTPClient(&http.Client{
+ Transport: &rewriteTransport{url: srv.URL},
+ })
+ defer restore()
+
+ err := verifyChecksum(context.Background(), "1.0.0", archivePath)
+ if err != nil {
t.Errorf("verifyChecksum() = %v, want nil", err)
}
})
t.Run("mismatched checksum", func(t *testing.T) {
wrongHash := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
- body := []byte(fmt.Sprintf("%s %s\n", wrongHash, expectedArchiveName))
- serve(t, signedChecksumsHandler(body, priv))
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "%s %s\n", wrongHash, expectedArchiveName)
+ }))
+ defer srv.Close()
+
+ restore := swapHTTPClient(&http.Client{
+ Transport: &rewriteTransport{url: srv.URL},
+ })
+ defer restore()
+
err := verifyChecksum(context.Background(), "1.0.0", archivePath)
if err == nil {
- t.Fatal("verifyChecksum() with wrong hash should return error")
+ t.Error("verifyChecksum() with wrong hash should return error")
}
if !strings.Contains(err.Error(), "SHA256 mismatch") {
t.Errorf("verifyChecksum() error = %q, want to contain 'SHA256 mismatch'", err)
@@ -744,74 +730,39 @@ func TestVerifyChecksumWithHTTPTest(t *testing.T) {
})
t.Run("archive not in checksums", func(t *testing.T) {
- body := []byte("abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 some_other_file.tar.gz\n")
- serve(t, signedChecksumsHandler(body, priv))
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 some_other_file.tar.gz\n")
+ }))
+ defer srv.Close()
+
+ restore := swapHTTPClient(&http.Client{
+ Transport: &rewriteTransport{url: srv.URL},
+ })
+ defer restore()
+
err := verifyChecksum(context.Background(), "1.0.0", archivePath)
if err == nil {
- t.Fatal("verifyChecksum() with missing entry should return error")
+ t.Error("verifyChecksum() with missing entry should return error")
}
if !strings.Contains(err.Error(), "no checksum found") {
t.Errorf("verifyChecksum() error = %q, want to contain 'no checksum found'", err)
}
})
- t.Run("bad signature rejected", func(t *testing.T) {
- _, otherPriv, _ := ed25519.GenerateKey(rand.Reader)
- body := []byte(fmt.Sprintf("%s %s\n", correctHash, expectedArchiveName))
- // Correct checksums, but signed by the WRONG key → must be rejected BEFORE
- // any hash is trusted (the whole point of signing the checksums file).
- serve(t, signedChecksumsHandler(body, otherPriv))
- err := verifyChecksum(context.Background(), "1.0.0", archivePath)
- if err == nil {
- t.Fatal("verifyChecksum() with bad signature should return error")
- }
- if !strings.Contains(err.Error(), "verify signature") {
- t.Errorf("verifyChecksum() error = %q, want to contain 'verify signature'", err)
- }
- })
-
- t.Run("missing signature rejected", func(t *testing.T) {
- body := []byte(fmt.Sprintf("%s %s\n", correctHash, expectedArchiveName))
- // 404 on .sig while a pubkey is configured → can't verify → hard fail.
- serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write(body)
- }))
- err := verifyChecksum(context.Background(), "1.0.0", archivePath)
- if err == nil {
- t.Fatal("verifyChecksum() with missing signature should return error")
- }
- if !strings.Contains(err.Error(), "verify signature") {
- t.Errorf("verifyChecksum() error = %q, want to contain 'verify signature'", err)
- }
- })
-
- t.Run("allow-unsigned skips signature", func(t *testing.T) {
- body := []byte(fmt.Sprintf("%s %s\n", correctHash, expectedArchiveName))
- // No .sig served — verifyChecksumOnly (the --allow-unsigned path) must still
- // succeed on the checksum alone.
- serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
- http.NotFound(w, r)
- return
- }
- _, _ = w.Write(body)
- }))
- if err := verifyChecksumOnly(context.Background(), "1.0.0", archivePath); err != nil {
- t.Errorf("verifyChecksumOnly() = %v, want nil (signature skipped)", err)
- }
- })
-
t.Run("checksums server error", func(t *testing.T) {
- serve(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
}))
+ defer srv.Close()
+
+ restore := swapHTTPClient(&http.Client{
+ Transport: &rewriteTransport{url: srv.URL},
+ })
+ defer restore()
+
err := verifyChecksum(context.Background(), "1.0.0", archivePath)
if err == nil {
- t.Fatal("verifyChecksum() with server error should return error")
+ t.Error("verifyChecksum() with server error should return error")
}
if !strings.Contains(err.Error(), "HTTP 500") {
t.Errorf("verifyChecksum() error = %q, want to contain 'HTTP 500'", err)
@@ -819,9 +770,18 @@ func TestVerifyChecksumWithHTTPTest(t *testing.T) {
})
t.Run("nonexistent archive file", func(t *testing.T) {
- body := []byte(fmt.Sprintf("%s %s\n", correctHash, expectedArchiveName))
- serve(t, signedChecksumsHandler(body, priv))
- if err := verifyChecksum(context.Background(), "1.0.0", "/nonexistent-archive-path"); err == nil {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "%s %s\n", correctHash, expectedArchiveName)
+ }))
+ defer srv.Close()
+
+ restore := swapHTTPClient(&http.Client{
+ Transport: &rewriteTransport{url: srv.URL},
+ })
+ defer restore()
+
+ err := verifyChecksum(context.Background(), "1.0.0", "/nonexistent-archive-path")
+ if err == nil {
t.Error("verifyChecksum() with nonexistent archive should return error")
}
})
@@ -832,13 +792,6 @@ func TestVerifyChecksumCaseInsensitive(t *testing.T) {
t.Skip("tar.gz test only on unix")
}
- // Real flow: signature ENABLED with a test key + a valid .sig over the body.
- pub, priv, err := ed25519.GenerateKey(rand.Reader)
- if err != nil {
- t.Fatalf("generate keypair: %v", err)
- }
- withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
-
dir := t.TempDir()
archiveContent := []byte("case-insensitive-hash-test")
archivePath := filepath.Join(dir, "archive.tar.gz")
@@ -848,9 +801,10 @@ func TestVerifyChecksumCaseInsensitive(t *testing.T) {
// Use uppercase hash in checksums.txt — verifyChecksum uses EqualFold
upperHash := strings.ToUpper(hex.EncodeToString(h[:]))
expectedArchiveName := archiveName("1.0.0")
- body := []byte(fmt.Sprintf("%s %s\n", upperHash, expectedArchiveName))
- srv := httptest.NewServer(signedChecksumsHandler(body, priv))
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ fmt.Fprintf(w, "%s %s\n", upperHash, expectedArchiveName)
+ }))
defer srv.Close()
restore := swapHTTPClient(&http.Client{
@@ -858,7 +812,8 @@ func TestVerifyChecksumCaseInsensitive(t *testing.T) {
})
defer restore()
- if err := verifyChecksum(context.Background(), "1.0.0", archivePath); err != nil {
+ err := verifyChecksum(context.Background(), "1.0.0", archivePath)
+ if err != nil {
t.Errorf("verifyChecksum() with uppercase hash = %v, want nil", err)
}
}
diff --git a/internal/usenet/postprocess/par2.go b/internal/usenet/postprocess/par2.go
index e97b860..3eb96c3 100644
--- a/internal/usenet/postprocess/par2.go
+++ b/internal/usenet/postprocess/par2.go
@@ -1,36 +1,24 @@
package postprocess
import (
- "errors"
"fmt"
"log"
"os/exec"
"strings"
)
-// ErrPar2NotInstalled is returned by Par2Verify/Par2Repair when parity data is
-// present but the `par2` binary is missing. The caller MUST surface this rather
-// than treat it as "verified OK" — a download that shipped parity but could not
-// be checked is delivered UNVERIFIED, not verified.
-var ErrPar2NotInstalled = errors.New("par2 not installed")
-
-// par2Lookup probes whether the par2 binary is on PATH. It's a package var so
-// tests can simulate a missing binary without touching the real PATH.
-var par2Lookup = func() bool {
+// Par2Available checks if par2cmdline is installed.
+func Par2Available() bool {
_, err := exec.LookPath("par2")
return err == nil
}
-// Par2Available checks if par2cmdline is installed.
-func Par2Available() bool { return par2Lookup() }
-
-// Par2Verify verifies files using a par2 file. Returns nil on success,
-// ErrPar2NotInstalled when the binary is missing (parity present but unchecked —
-// the caller must surface it, NOT treat it as verified), a *Par2RepairableError
-// when repair is possible, or another error on failure.
+// Par2Verify verifies files using a par2 file.
+// Returns nil if verification passes, error otherwise.
func Par2Verify(par2File string) error {
if !Par2Available() {
- return ErrPar2NotInstalled
+ log.Printf("[usenet] par2 not installed, skipping verification")
+ return nil
}
cmd := exec.Command("par2", "verify", par2File)
@@ -54,7 +42,7 @@ func Par2Verify(par2File string) error {
// Par2Repair attempts to repair files using par2 parity data.
func Par2Repair(par2File string) error {
if !Par2Available() {
- return ErrPar2NotInstalled
+ return fmt.Errorf("par2 not installed")
}
cmd := exec.Command("par2", "repair", par2File)
diff --git a/internal/usenet/postprocess/pipeline.go b/internal/usenet/postprocess/pipeline.go
index 599d7b8..1770382 100644
--- a/internal/usenet/postprocess/pipeline.go
+++ b/internal/usenet/postprocess/pipeline.go
@@ -1,7 +1,6 @@
package postprocess
import (
- "errors"
"fmt"
"log"
"os"
@@ -17,12 +16,6 @@ type Result struct {
Files []string // all final files
Repaired bool // whether par2 repair was needed
Extracted bool // whether archive extraction was performed
- // VerifyNote is non-empty when par2 verification was DEGRADED — parity shipped
- // but could not be confirmed (par2 missing, repair failed, verify error). The
- // download is still delivered, but the caller surfaces this so the user knows
- // the file is unverified rather than silently assuming it's good. Empty means
- // either "verified OK" or "no parity shipped" — both are non-degraded.
- VerifyNote string
}
// Options configures post-processing behavior.
@@ -36,37 +29,21 @@ type Options struct {
func Process(dir string, downloadedFiles map[string]string, opts Options) (*Result, error) {
result := &Result{}
- // Step 1: Par2 verification and repair. Parity is optional, so a missing
- // binary or a failed repair does NOT abort the download — but it MUST be
- // surfaced (result.VerifyNote + a WARNING) instead of silently delivering an
- // unverified file as if it had passed.
+ // Step 1: Par2 verification and repair
par2File := findPar2File(downloadedFiles)
if par2File != "" {
- var repairable *Par2RepairableError
err := Par2Verify(par2File)
- switch {
- case err == nil:
- // Verified OK — nothing to surface.
- case errors.Is(err, ErrPar2NotInstalled):
- result.VerifyNote = "par2 parity present but `par2` is not installed — delivered UNVERIFIED (install par2cmdline to enable verification/repair)"
- log.Printf("[usenet] WARNING: %s", result.VerifyNote)
- case errors.As(err, &repairable):
- log.Printf("[usenet] par2: corruption detected, attempting repair...")
- repairErr := Par2Repair(par2File)
- switch {
- case repairErr == nil:
- result.Repaired = true
- log.Printf("[usenet] par2: repair successful")
- case errors.Is(repairErr, ErrPar2NotInstalled):
- result.VerifyNote = "par2 corruption detected but `par2` is not installed — cannot repair, delivered POSSIBLY CORRUPT"
- log.Printf("[usenet] WARNING: %s", result.VerifyNote)
- default:
- result.VerifyNote = fmt.Sprintf("par2 repair failed — file may be corrupt: %v", repairErr)
- log.Printf("[usenet] WARNING: %s", result.VerifyNote)
+ if err != nil {
+ if _, ok := err.(*Par2RepairableError); ok {
+ log.Printf("[usenet] attempting par2 repair...")
+ if repairErr := Par2Repair(par2File); repairErr != nil {
+ log.Printf("[usenet] par2 repair failed: %v", repairErr)
+ } else {
+ result.Repaired = true
+ }
+ } else {
+ log.Printf("[usenet] par2 verification error: %v", err)
}
- default:
- result.VerifyNote = fmt.Sprintf("par2 verification error — file may be corrupt: %v", err)
- log.Printf("[usenet] WARNING: %s", result.VerifyNote)
}
}
diff --git a/internal/usenet/postprocess/pipeline_test.go b/internal/usenet/postprocess/pipeline_test.go
index 3e338b6..f3c0cc9 100644
--- a/internal/usenet/postprocess/pipeline_test.go
+++ b/internal/usenet/postprocess/pipeline_test.go
@@ -6,38 +6,6 @@ import (
"testing"
)
-// TestProcess_Par2MissingSurfaced verifies that when parity is present but the
-// par2 binary is missing, Process does NOT silently report success: it surfaces
-// the degraded state via VerifyNote and leaves Verified false (while still
-// delivering the file).
-func TestProcess_Par2MissingSurfaced(t *testing.T) {
- orig := par2Lookup
- par2Lookup = func() bool { return false }
- defer func() { par2Lookup = orig }()
-
- dir := t.TempDir()
- par2Path := filepath.Join(dir, "release.par2")
- if err := os.WriteFile(par2Path, []byte("fake parity"), 0o644); err != nil {
- t.Fatal(err)
- }
- vid := filepath.Join(dir, "movie.mkv")
- if err := os.WriteFile(vid, []byte("video data"), 0o644); err != nil {
- t.Fatal(err)
- }
- files := map[string]string{"release.par2": par2Path, "movie.mkv": vid}
-
- res, err := Process(dir, files, Options{})
- if err != nil {
- t.Fatalf("Process: %v", err)
- }
- if res.VerifyNote == "" {
- t.Error("VerifyNote must be set (not silent) when par2 is missing")
- }
- if res.FinalPath != vid {
- t.Errorf("FinalPath = %q, want %q (file still delivered)", res.FinalPath, vid)
- }
-}
-
func TestFindPar2File(t *testing.T) {
dir := t.TempDir()
diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go
index c2c1e76..7f50ea1 100644
--- a/internal/vpn/vpn.go
+++ b/internal/vpn/vpn.go
@@ -174,11 +174,11 @@ type wgConf struct {
dns []netip.Addr
mtu int
- peerPublicKey string // hex
- presharedKey string // hex (optional)
- endpoint string // resolved ip:port
- allowedIPs []string
- keepalive int
+ peerPublicKey string // hex
+ presharedKey string // hex (optional)
+ endpoint string // resolved ip:port
+ allowedIPs []string
+ keepalive int
}
func (w *wgConf) uapi() string {
diff --git a/scripts/release.sh b/scripts/release.sh
index 46862be..da9b911 100755
--- a/scripts/release.sh
+++ b/scripts/release.sh
@@ -55,17 +55,6 @@ fi
CURRENT_BRANCH=$(git branch --show-current)
[ "$CURRENT_BRANCH" = "main" ] || warn "Not on main branch (current: $CURRENT_BRANCH)"
-HEAD_SUBJECT=$(git log -1 --pretty=%s)
-if [[ "$HEAD_SUBJECT" =~ \(([0-9]+\.[0-9]+\.[0-9]+)\) ]]; then
- die "HEAD commit subject contains inline version bump: \"$HEAD_SUBJECT\"
-Release contract: version bumps MUST live in a dedicated 'chore(release): X.Y.Z' commit.
-Revert the inline bump and re-run this script — it will create the proper commit."
-fi
-if [[ "$HEAD_SUBJECT" =~ ^chore\(release\): ]]; then
- die "HEAD is already a chore(release) commit: \"$HEAD_SUBJECT\"
-Nothing new to release. Add commits since the last release or amend intentionally outside this script."
-fi
-
# ── Resolve version ────────────────────────────────────────────────
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
LATEST_VERSION="${LATEST_TAG#v}"
diff --git a/scripts/ship.sh b/scripts/ship.sh
deleted file mode 100755
index e828759..0000000
--- a/scripts/ship.sh
+++ /dev/null
@@ -1,242 +0,0 @@
-#!/usr/bin/env bash
-#
-# ship.sh — End-to-end CLI release pipeline.
-#
-# Standalone backup for when GitHub Actions is unavailable (org shadow-ban,
-# CI outage, etc). Mirrors what release.yml + docker job in CI would do.
-#
-# Pre-requisites:
-# - scripts/release.sh already ran → version.go bumped + tag created locally
-# - SENTRY_DSN exported (Sentry disabled in build if missing)
-# - docker logged in to docker.io as the org user
-# - SSH key for Hetzner publishing (see publish-cli-release.sh)
-#
-# Pipeline:
-# 1. Sanity: clean tree, tag at HEAD, version.go matches
-# 2. goreleaser build (skip GH publish — produces dist/*)
-# 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh
-# 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub
-# 5. Smoke checks (torrentclaw.com/version + docker run image version)
-# 6. Prune Forgejo releases older than FORGEJO_PRUNE_DAYS (default 90)
-# 7. Optional `git push --follow-tags`
-#
-# Usage:
-# scripts/ship.sh Detect version from internal/cmd/version.go
-# scripts/ship.sh 0.9.12 Explicit version
-# scripts/ship.sh --dry-run Preview steps, no side effects
-# scripts/ship.sh --push 0.9.12 Also git-push tag to GH afterwards
-#
-# Env knobs:
-# SENTRY_DSN telemetry DSN injected at build time
-# RELEASE_SIGNING_PUBKEY ed25519 pubkey (base64) for self-update signature check
-# DOCKER_IMAGE default torrentclaw/unarr
-# PUBLISH_SCRIPT default ../torrentclaw-web/scripts/publish-cli-release.sh
-# SKIP_DOCKER=1 skip Docker build/push
-# SKIP_HETZNER=1 skip Hetzner publish
-# SKIP_SMOKE=1 skip smoke checks
-# SKIP_FORGEJO_PRUNE=1 skip Forgejo retention prune
-# FORGEJO_TOKEN PAT with write:repository for prune (no token = skip + warn)
-# FORGEJO_PRUNE_DAYS retention window, default 90 days
-# FORGEJO_REPO default torrentclaw/unarr
-#
-set -euo pipefail
-
-REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
-cd "$REPO_DIR"
-
-DOCKER_IMAGE="${DOCKER_IMAGE:-torrentclaw/unarr}"
-PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-cli-release.sh}"
-SKIP_DOCKER="${SKIP_DOCKER:-0}"
-SKIP_HETZNER="${SKIP_HETZNER:-0}"
-SKIP_SMOKE="${SKIP_SMOKE:-0}"
-SKIP_FORGEJO_PRUNE="${SKIP_FORGEJO_PRUNE:-0}"
-FORGEJO_PRUNE_DAYS="${FORGEJO_PRUNE_DAYS:-90}"
-FORGEJO_REPO="${FORGEJO_REPO:-torrentclaw/unarr}"
-FORGEJO_BASE="${FORGEJO_BASE:-https://git.torrentclaw.com}"
-
-DRY_RUN=false
-PUSH_TAG=false
-VERSION=""
-
-RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m'
-info() { echo -e "${CYAN}▸${NC} $*"; }
-ok() { echo -e "${GREEN}✓${NC} $*"; }
-warn() { echo -e "${YELLOW}⚠${NC} $*"; }
-die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; }
-
-for a in "$@"; do
- case "$a" in
- --dry-run) DRY_RUN=true ;;
- --push) PUSH_TAG=true ;;
- -h|--help)
- sed -n '2,/^set /p' "$0" | sed 's/^#\s\?//;$d'
- exit 0 ;;
- [0-9]*) VERSION="$a" ;;
- *) die "unknown arg: $a (use --help)" ;;
- esac
-done
-
-read_version_go() {
- grep 'var Version' internal/cmd/version.go | sed 's/.*"\(.*\)".*/\1/'
-}
-
-REPO_VERSION="$(read_version_go)"
-[ -z "$VERSION" ] && VERSION="$REPO_VERSION"
-[ -n "$VERSION" ] || die "cannot detect version (pass explicit X.Y.Z)"
-TAG="v$VERSION"
-MINOR="${VERSION%.*}"
-
-echo ""
-echo -e " ${BOLD}Ship Plan${NC}"
-echo -e " ─────────────────────────────"
-echo -e " Version: ${GREEN}$TAG${NC}"
-echo -e " Docker image: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}"
-echo -e " Skip Hetzner: $SKIP_HETZNER"
-echo -e " Skip Docker: $SKIP_DOCKER"
-echo -e " Push to GH: $PUSH_TAG"
-echo -e " Dry run: $DRY_RUN"
-echo ""
-
-# Sanity
-[ "$REPO_VERSION" = "$VERSION" ] || die "version.go=$REPO_VERSION ≠ requested $VERSION (bump with make release-* first)"
-
-if [ "$DRY_RUN" = false ]; then
- [ -z "$(git status --porcelain)" ] || die "working tree dirty"
- git rev-parse "$TAG" >/dev/null 2>&1 || die "tag $TAG missing — run scripts/release.sh first"
-
- HEAD_SHA="$(git rev-parse HEAD)"
- TAG_SHA="$(git rev-parse "$TAG^{commit}")"
- [ "$HEAD_SHA" = "$TAG_SHA" ] || die "HEAD ($HEAD_SHA) ≠ tag commit ($TAG_SHA) — checkout $TAG first"
-
- command -v goreleaser >/dev/null || die "goreleaser not installed"
- [ "$SKIP_DOCKER" = "1" ] || command -v docker >/dev/null || die "docker not installed"
- [ "$SKIP_HETZNER" = "1" ] || [ -x "$PUBLISH_SCRIPT" ] || die "publish script missing or not executable: $PUBLISH_SCRIPT"
-
- if [ -z "${SENTRY_DSN:-}" ]; then
- warn "SENTRY_DSN unset — built binaries will have Sentry disabled"
- fi
-fi
-
-# Release signing key — releases MUST be signed (the goreleaser `signs:` block
-# consumes RELEASE_SIGNING_KEY to produce checksums.txt.sig, verified by the
-# compiled-in public key). Prefer an explicit env var, else the local keyfile.
-SIGNING_KEY_FILE="${RELEASE_SIGNING_KEY_FILE:-$HOME/.config/unarr-release/signing.key}"
-if [ -z "${RELEASE_SIGNING_KEY:-}" ] && [ -f "$SIGNING_KEY_FILE" ]; then
- RELEASE_SIGNING_KEY="$(tr -d '\r\n' < "$SIGNING_KEY_FILE")"
-fi
-if [ -z "${RELEASE_SIGNING_KEY:-}" ]; then
- if [ "$DRY_RUN" = true ]; then
- warn "no signing key (RELEASE_SIGNING_KEY env or $SIGNING_KEY_FILE) — a real ship would FAIL: releases must be signed"
- else
- die "no release signing key: export RELEASE_SIGNING_KEY or create $SIGNING_KEY_FILE — releases MUST be signed"
- fi
-fi
-export RELEASE_SIGNING_KEY
-
-if [ "$DRY_RUN" = true ]; then
- ok "Dry run complete — no changes made"
- exit 0
-fi
-
-# 1. Build (+ sign checksums via the goreleaser `signs:` block, which consumes
-# RELEASE_SIGNING_KEY — exported above; missing key already aborted the run).
-info "goreleaser build + sign ($TAG)"
-SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_KEY="$RELEASE_SIGNING_KEY" \
- goreleaser release --clean --skip=publish
-[ -f dist/checksums.txt.sig ] || die "checksums.txt.sig not produced — signing step did not run"
-ok "dist/ ready (checksums.txt + checksums.txt.sig)"
-
-# 2. Hetzner
-if [ "$SKIP_HETZNER" != "1" ]; then
- info "publishing to Hetzner releases volume"
- "$PUBLISH_SCRIPT" "$VERSION"
- ok "Hetzner version.txt flipped to $VERSION"
-fi
-
-# 3. Docker
-if [ "$SKIP_DOCKER" != "1" ]; then
- info "docker buildx multi-arch push ($DOCKER_IMAGE:$VERSION, :$MINOR, :latest)"
- docker buildx build \
- --platform linux/amd64,linux/arm64 \
- --build-arg VERSION="$TAG" \
- -t "$DOCKER_IMAGE:$VERSION" \
- -t "$DOCKER_IMAGE:$MINOR" \
- -t "$DOCKER_IMAGE:latest" \
- --push .
- ok "Docker Hub: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}"
-fi
-
-# 4. Smoke
-if [ "$SKIP_SMOKE" != "1" ]; then
- info "smoke checks"
- if [ "$SKIP_HETZNER" != "1" ]; then
- LIVE_VERSION="$(curl -fsSL https://torrentclaw.com/version 2>/dev/null | tr -d '[:space:]' || echo '')"
- if [ "$LIVE_VERSION" = "$VERSION" ]; then
- ok "torrentclaw.com/version = $LIVE_VERSION"
- else
- warn "torrentclaw.com/version = '$LIVE_VERSION' (expected $VERSION)"
- fi
- fi
-
- if [ "$SKIP_DOCKER" != "1" ]; then
- # Keep any prerelease/build suffix (e.g. -beta) — `v[0-9.]+` alone would
- # truncate "v1.0.1-beta" to "v1.0.1" and false-warn on a correct image.
- DOCKER_VERSION="$(docker run --rm "$DOCKER_IMAGE:$VERSION" version 2>/dev/null | grep -oE 'v[0-9][0-9A-Za-z.+-]*' | head -1)"
- if [ "$DOCKER_VERSION" = "$TAG" ]; then
- ok "docker image $DOCKER_IMAGE:$VERSION reports $DOCKER_VERSION"
- else
- warn "docker image reports '$DOCKER_VERSION' (expected $TAG)"
- fi
- fi
-fi
-
-# 6. Forgejo retention prune
-if [ "$SKIP_FORGEJO_PRUNE" != "1" ]; then
- if [ -z "${FORGEJO_TOKEN:-}" ]; then
- warn "FORGEJO_TOKEN not set — skipping Forgejo prune (set it to enable >${FORGEJO_PRUNE_DAYS}-day cleanup)"
- else
- info "pruning Forgejo releases older than $FORGEJO_PRUNE_DAYS days"
- FORGEJO_API="$FORGEJO_BASE/api/v1/repos/$FORGEJO_REPO/releases"
- RELEASES_JSON="$(curl -fsSL -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API?limit=50" || echo '[]')"
- PRUNE_IDS="$(echo "$RELEASES_JSON" | python3 -c "
-import json, sys
-from datetime import datetime, timedelta, timezone
-days = int('${FORGEJO_PRUNE_DAYS}')
-cutoff = datetime.now(timezone.utc) - timedelta(days=days)
-for r in json.load(sys.stdin):
- created = datetime.fromisoformat(r['created_at'].replace('Z', '+00:00'))
- if created < cutoff:
- print(f\"{r['id']}\t{r['tag_name']}\t{r['created_at']}\")
-" 2>/dev/null || true)"
- DELETED=0
- FAILED=0
- if [ -n "$PRUNE_IDS" ]; then
- while IFS=$'\t' read -r REL_ID REL_TAG REL_CREATED; do
- [ -z "$REL_ID" ] && continue
- CODE="$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API/$REL_ID")"
- if [ "$CODE" = "204" ]; then
- echo " deleted $REL_TAG (created $REL_CREATED)"
- DELETED=$((DELETED + 1))
- else
- warn " failed to delete $REL_TAG (id=$REL_ID, http=$CODE)"
- FAILED=$((FAILED + 1))
- fi
- done <<< "$PRUNE_IDS"
- fi
- if [ "$FAILED" -gt 0 ]; then
- warn "Forgejo prune: $DELETED removed, $FAILED failed"
- else
- ok "Forgejo prune: $DELETED release(s) removed (>${FORGEJO_PRUNE_DAYS} days old)"
- fi
- fi
-fi
-
-# 7. Optional push
-if [ "$PUSH_TAG" = true ]; then
- info "git push origin main --follow-tags"
- git push origin main --follow-tags
- ok "tag $TAG pushed to GitHub"
-fi
-
-echo ""
-ok "${BOLD}$TAG shipped${NC}"