diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4091938 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# 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/.github/workflows/ci.yml b/.forgejo/workflows/ci.yml similarity index 61% rename from .github/workflows/ci.yml rename to .forgejo/workflows/ci.yml index 7dabcc4..82ee799 100644 --- a/.github/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,35 +12,26 @@ permissions: jobs: test: name: Test - runs-on: ubuntu-latest - strategy: - matrix: - go-version: ["1.25"] + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v4 - name: Run tests run: go test -v -race -count=1 ./... build: name: Build - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 strategy: matrix: goos: [linux, darwin, windows] goarch: [amd64, arm64] steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - uses: actions/checkout@v4 - name: Build env: @@ -50,30 +41,30 @@ jobs: lint: name: Lint - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \ + | sh -s -- -b /usr/local/bin v2.11.4 - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.11.4 + run: golangci-lint run ./... coverage: name: Coverage - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - name: Install python3 + run: apt-get update && apt-get install -y --no-install-recommends python3 - name: Run tests with coverage (all packages) run: | @@ -102,24 +93,13 @@ jobs: print('OK: Coverage meets minimum threshold') " - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 - with: - files: ./coverage.out - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - vet: name: Vet - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - uses: actions/checkout@v4 - name: Run go vet run: go vet ./... diff --git a/.forgejo/workflows/docker-rebuild.yml b/.forgejo/workflows/docker-rebuild.yml new file mode 100644 index 0000000..34cc3d6 --- /dev/null +++ b/.forgejo/workflows/docker-rebuild.yml @@ -0,0 +1,61 @@ +# Rebuilds and re-pushes the `latest` image without a version bump so newly +# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned +# tags are immutable and never touched here. Runs weekly and on demand. +name: Docker rebuild + +on: + schedule: + # Mondays 04:17 UTC (off the hour to avoid the scheduler rush) + - cron: "17 4 * * 1" + workflow_dispatch: + +jobs: + rebuild: + runs-on: docker + container: + image: docker.io/library/docker:27-cli + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install build deps + run: apk add --no-cache curl git bash + + - name: Install buildx + run: | + mkdir -p ~/.docker/cli-plugins + curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \ + -o ~/.docker/cli-plugins/docker-buildx + chmod +x ~/.docker/cli-plugins/docker-buildx + + - name: Set up qemu + run: docker run --rm --privileged tonistiigi/binfmt --install all + + # Stamp the binary with the most recent release tag (not "dev"). + - name: Resolve version + id: ver + run: | + v=$(git describe --tags --abbrev=0 2>/dev/null || echo dev) + echo "version=$v" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + env: + DH_USER: ${{ secrets.DOCKERHUB_USERNAME }} + DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin + + - name: Build + push (refresh latest) + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + docker buildx create --name builder --use --driver docker-container + # Refresh the floating tag only — never overwrite a versioned release. + # Force a fresh base pull so apk upgrade picks up new patches. + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg "VERSION=$VERSION" \ + --tag "torrentclaw/unarr:latest" \ + --no-cache \ + --push \ + . diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..d757612 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,118 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + +permissions: + contents: write + +jobs: + release: + runs-on: docker + container: + image: docker.io/library/golang:1.25 + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install build deps (bash, curl, jq, ffmpeg fetch deps) + run: | + apt-get update + apt-get install -y --no-install-recommends bash curl ca-certificates jq xz-utils unzip + + - name: Install goreleaser + run: | + curl -sSfL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz \ + | tar -xz -C /usr/local/bin goreleaser + + - name: Run goreleaser + env: + # Forgejo runner auto-injects GITHUB_TOKEN (a per-job, instance-scoped + # token usable against the Forgejo REST API). goreleaser only accepts + # one token; with both GITHUB_TOKEN + GITEA_TOKEN set it errors out + # ("multiple tokens"). Unset GITHUB_TOKEN before invoking goreleaser so + # it picks the Gitea code path + the gitea_urls block in .goreleaser.yml. + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + # Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser + # accepts it and the resulting binary disables signature checks + # (back-compat: pre-signing releases continue to update). Set + # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret) + # to turn verification on. + RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }} + run: | + unset GITHUB_TOKEN + goreleaser release --clean + + - name: Sign checksums.txt with ed25519 + if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }} + env: + RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} + RELEASE_TAG: ${{ github.ref_name }} + FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Tailscale IP — domain-agnostic; the runner shares the dokploy-network with + # forgejo (hostname `forgejo`), so the in-cluster hostname is fastest, but the + # Tailscale IP is the documented fallback. + FORGEJO_API: http://forgejo:3000/api/v1 + REPO: torrentclaw/unarr + run: | + set -euo pipefail + go run ./scripts/sign-checksums \ + -key "$RELEASE_SIGNING_KEY" \ + -in dist/checksums.txt \ + -out dist/checksums.txt.sig + + # Find the release ID for this tag, then upload the sig as an asset. + rel_id=$(curl -sSf "$FORGEJO_API/repos/$REPO/releases/tags/$RELEASE_TAG" \ + -H "Authorization: token $FORGEJO_TOKEN" | jq -r '.id') + curl -sSf -X POST \ + "$FORGEJO_API/repos/$REPO/releases/$rel_id/assets?name=checksums.txt.sig" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -F "attachment=@dist/checksums.txt.sig" + + docker: + needs: release + runs-on: docker + container: + # Docker-in-Docker capable image — buildx + qemu pre-installed. + image: docker.io/library/docker:27-cli + steps: + - uses: actions/checkout@v4 + + - name: Install buildx + run: | + apk add --no-cache curl + mkdir -p ~/.docker/cli-plugins + curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \ + -o ~/.docker/cli-plugins/docker-buildx + chmod +x ~/.docker/cli-plugins/docker-buildx + + - name: Login to Docker Hub + env: + DH_USER: ${{ secrets.DOCKERHUB_USERNAME }} + DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin + + - name: Set up qemu + run: docker run --rm --privileged tonistiigi/binfmt --install all + + - name: Build + push multi-arch image + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + VERSION_SEMVER="${VERSION#v}" + MAJOR_MINOR="${VERSION_SEMVER%.*}" + docker buildx create --name builder --use --driver docker-container + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg "VERSION=$VERSION" \ + --tag "torrentclaw/unarr:$VERSION_SEMVER" \ + --tag "torrentclaw/unarr:$MAJOR_MINOR" \ + --tag "torrentclaw/unarr:latest" \ + --push \ + . diff --git a/.github/workflows/docker-rebuild.yml b/.github/workflows/docker-rebuild.yml deleted file mode 100644 index c1634f1..0000000 --- a/.github/workflows/docker-rebuild.yml +++ /dev/null @@ -1,52 +0,0 @@ -# Rebuilds and re-pushes the `latest` image without a version bump so newly -# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned -# tags are immutable and never touched here. Runs weekly and on demand. -name: Docker rebuild - -on: - schedule: - # Mondays 04:17 UTC (off the hour to avoid the scheduler rush) - - cron: "17 4 * * 1" - workflow_dispatch: - -jobs: - rebuild: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - # Stamp the binary with the most recent release tag (not "dev"). - - name: Resolve version - id: ver - run: echo "version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: docker/build-push-action@v7 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - # Refresh the floating tag only — never overwrite a versioned release. - tags: torrentclaw/unarr:latest - build-args: | - VERSION=${{ steps.ver.outputs.version }} - # Force a fresh base pull so apk upgrade picks up new patches. - no-cache: true - - - name: Scan image for fixable CVEs (gate) - uses: docker/scout-action@v1 - with: - command: cves - image: torrentclaw/unarr:latest - only-severities: critical,high - only-fixed: true - exit-code: true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index d0c683d..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Deploy install scripts to Pages - -on: - push: - branches: [main] - paths: - - install.sh - - install.ps1 - - CNAME - - .nojekyll - - .github/workflows/pages.yml - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - uses: actions/configure-pages@v5 - - name: Stage install scripts - run: | - mkdir -p _site - cp install.sh install.ps1 _site/ - [ -f CNAME ] && cp CNAME _site/ - touch _site/.nojekyll - # Also index page (humans landing) - cat > _site/index.html <<'HTML' - - unarr installer -

unarr CLI installer

-
Linux/macOS:  curl -fsSL https://unarr.torrentclaw.com/install.sh | sh
-          Windows:      irm https://unarr.torrentclaw.com/install.ps1 | iex
-

Source: github.com/torrentclaw/unarr

- - HTML - - uses: actions/upload-pages-artifact@v3 - with: - path: _site - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index dcb49ce..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - uses: goreleaser/goreleaser-action@v6 - with: - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - # Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser - # accepts it and the resulting binary disables signature checks - # (back-compat: pre-signing releases continue to update). Set - # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret) - # to turn verification on. - RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }} - - - name: Sign checksums.txt with ed25519 - # Reference secrets.X directly — step-level env defined in this same - # step is unreliable to read from this step's own if: expression. - if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }} - env: - RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} - RELEASE_TAG: ${{ github.ref_name }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - go run ./scripts/sign-checksums \ - -key "$RELEASE_SIGNING_KEY" \ - -in dist/checksums.txt \ - -out dist/checksums.txt.sig - gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber - - docker: - needs: release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v6 - with: - images: torrentclaw/unarr - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: docker/build-push-action@v7 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ github.ref_name }} - - # CVE gate. Fails the release on FIXABLE critical/high only — unfixed - # upstream ffmpeg codec CVEs are accepted (see SECURITY.md), so the - # codec noise does not block. Runs post-push (image already published); - # a failure here flags that a fixable CVE slipped through. - - name: Scan image for fixable CVEs (gate) - uses: docker/scout-action@v1 - with: - command: cves - image: torrentclaw/unarr:latest - only-severities: critical,high - only-fixed: true - exit-code: true - - # Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a - # description-API auth hiccup must not undo a successful image push. - - name: Update Docker Hub description - uses: peter-evans/dockerhub-description@v4 - continue-on-error: true - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: torrentclaw/unarr - readme-filepath: ./DOCKERHUB.md - short-description: "unarr — the single binary that replaces your *arr stack" - - - virustotal: - needs: release - runs-on: ubuntu-latest - if: vars.VT_ENABLED == 'true' - steps: - - name: Get release tag - id: tag - run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - - - name: Download release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mkdir -p assets - gh release download "${{ steps.tag.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --dir assets \ - --pattern '*.tar.gz' \ - --pattern '*.zip' \ - --pattern 'checksums.txt' - - - name: Scan assets with VirusTotal - env: - VT_API_KEY: ${{ secrets.VT_API_KEY }} - run: | - mkdir -p results - for file in assets/*; do - filename=$(basename "$file") - echo "Uploading $filename to VirusTotal..." - - response=$(curl -s --request POST \ - --url https://www.virustotal.com/api/v3/files \ - --header "x-apikey: $VT_API_KEY" \ - --form "file=@$file") - - analysis_id=$(echo "$response" | jq -r '.data.id // empty') - if [ -z "$analysis_id" ]; then - echo "::warning::Failed to upload $filename: $response" - continue - fi - - echo "$filename=$analysis_id" >> results/scans.txt - echo " Analysis ID: $analysis_id" - - # Rate limit: VT free tier allows 4 req/min - sleep 16 - done - - - name: Wait for analysis completion - env: - VT_API_KEY: ${{ secrets.VT_API_KEY }} - run: | - echo "Waiting 60s for VirusTotal analysis to complete..." - sleep 60 - - vt_report="## 🛡️ VirusTotal Scan Results\n\n" - vt_report+="| File | Result | Link |\n" - vt_report+="|------|--------|------|\n" - - while IFS='=' read -r filename analysis_id; do - result=$(curl -s --request GET \ - --url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \ - --header "x-apikey: $VT_API_KEY") - - malicious=$(echo "$result" | jq -r '.data.attributes.stats.malicious // 0') - undetected=$(echo "$result" | jq -r '.data.attributes.stats.undetected // 0') - sha256=$(echo "$result" | jq -r '.meta.file_info.sha256 // empty') - - if [ "$malicious" = "0" ]; then - status="✅ Clean ($undetected engines)" - else - status="⚠️ $malicious detections" - fi - - link="https://www.virustotal.com/gui/file/$sha256" - vt_report+="| \`$filename\` | $status | [View]($link) |\n" - - sleep 16 - done < results/scans.txt - - echo -e "$vt_report" > results/report.md - cat results/report.md - - - name: Append scan results to release notes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - current_body=$(gh release view "${{ steps.tag.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --json body --jq '.body') - - new_body="${current_body} - - $(cat results/report.md)" - - gh release edit "${{ steps.tag.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --notes "$new_body" diff --git a/.gitignore b/.gitignore index 81f1284..8015bab 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ dist-ffbinaries/ # Docker tmp/ config/ -dist-ffbinaries/ \ No newline at end of file +dist-ffbinaries/ + +# Claude Code: keep entirely local, do not track +.claude/ \ No newline at end of file diff --git a/.goreleaser.yml b/.goreleaser.yml index 26ce802..08a604e 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 }} - # 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 }} + # 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. archives: - formats: [tar.gz] @@ -51,6 +51,28 @@ 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: @@ -59,6 +81,22 @@ changelog: - "^test:" - "^chore:" +# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN + +# these URLs and publishes the release there instead of GitHub. Reachable via +# `forgejo` hostname inside the dokploy-network (the runner shares it); for +# local goreleaser runs outside the network, override via env GITEA_API_URL. +# +# In goreleaser v2 `gitea_urls` is a top-level key (was nested under `release` +# in v1). +gitea_urls: + api: http://forgejo:3000/api/v1 + download: https://git.torrentclaw.com + skip_tls_verify: false + +release: + draft: false + prerelease: auto + # Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN) # Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN # brews: diff --git a/CHANGELOG.md b/CHANGELOG.md index 534bd99..affe5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,195 @@ 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.0.2-beta] - 2026-06-03 + + +### Added + +- **stream**: serve a `mode=stream` task from a debrid HTTPS link when the torrent is cached (debrid passthrough for external players / VLC), falling back to P2P stream-while-download when it isn't + +### Changed + +- **stream**: widen the debrid HEAD size-probe timeout to 15s to match the TLS handshake budget — a slow CDN no longer trips the old 10s and falls back to a guessed size + +## [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 +## [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 @@ -22,6 +211,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cors**: allow play from .to / staging / onion mirrors - **library**: classify resolution by width + height, not height alone - **transcode**: make preset libx264-only + restore quality opt-in + +### Other + +- **release**: 0.9.11 ## [0.9.8] - 2026-05-27 @@ -484,6 +677,14 @@ 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.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 diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 7a9bc0e..3df5b70 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -1,8 +1,9 @@ # unarr -**The single binary that replaces your whole *arr stack.** Search 30+ torrent -sources, inspect real quality before you download, grab subtitles, and manage -your media library — all from one terminal tool or a headless daemon. +**The single binary that replaces your whole *arr stack.** Built-in torrent, +debrid, and usenet engines. Stream, transcode, and organize your library from +one terminal — or run it as a headless daemon with a web dashboard, WireGuard +split-tunnel, and Cloudflare Funnel remote access. **[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)** diff --git a/Dockerfile b/Dockerfile index 64ea4e2..3707b62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # ---- Build stage ---- -FROM golang:1.25-alpine AS builder +# 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 RUN apk add --no-cache git ca-certificates @@ -13,34 +16,69 @@ RUN go mod download COPY . . ARG VERSION=dev -RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/ +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/ # ---- Runtime stage ---- -FROM alpine:3.22 +# 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 -# 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 +# 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. +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/* + +# TARGETARCH is set automatically by Docker buildx during cross-builds. +ARG TARGETARCH=amd64 + +# 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 case "$TARGETARCH" in \ + amd64) FF_ARCH=linux64 ;; \ + arm64) FF_ARCH=linuxarm64 ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&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 # Bundle cloudflared so `unarr funnel on` (default: on, see config defaults) # Just Works on a headless container with no first-run network round-trip. -# TARGETARCH is set automatically by Docker buildx during cross-builds. -ARG TARGETARCH=amd64 RUN case "$TARGETARCH" in \ amd64) CF_ARCH=amd64 ;; \ arm64) CF_ARCH=arm64 ;; \ arm) CF_ARCH=armhf ;; \ *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ esac && \ - wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ + wget -4 --tries=3 --timeout=30 -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 addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr +RUN groupadd -g 1000 unarr && useradd -u 1000 -g 1000 -m -d /home/unarr unarr # Default directories RUN mkdir -p /config /downloads /data && \ @@ -55,6 +93,23 @@ 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 new file mode 100644 index 0000000..3b2b693 --- /dev/null +++ b/Docs/plans/unarr-agent-roadmap.md @@ -0,0 +1,661 @@ +# 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 `