Compare commits
162 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f22d698da | ||
|
|
86f03ba787 | ||
|
|
c82826bf68 | ||
|
|
aba20e2078 | ||
|
|
8e37293b7d | ||
|
|
7877e1de42 | ||
|
|
2abaf74b13 | ||
|
|
1757bdabf5 | ||
|
|
547b0d4e37 | ||
|
|
1814d59e09 | ||
|
|
2148b0e2cc | ||
|
|
ccd50e7c8e | ||
|
|
b6ddeea129 | ||
|
|
e298ff6c05 | ||
|
|
5e5a719f27 | ||
|
|
cfaedb7f3b | ||
|
|
ef3b190e0b | ||
|
|
005a4380dd | ||
|
|
325c11c1eb | ||
|
|
ea152a2276 | ||
|
|
2b5a45674a | ||
|
|
f7ea06c70a | ||
|
|
f0ac905fdb | ||
|
|
c86e50245e | ||
|
|
bc6f85bf39 | ||
|
|
1c8cc1c409 | ||
|
|
8a47132f15 | ||
|
|
1e5de874cf | ||
|
|
178c16f458 | ||
|
|
7417fad45f | ||
|
|
08cb58073d | ||
|
|
c4ddd44a1a | ||
|
|
8accafbe59 | ||
|
|
6436c9fb6b | ||
|
|
864b6ea832 | ||
|
|
1052529ca2 | ||
|
|
96b23ed051 | ||
|
|
3d51013935 | ||
|
|
27bee8cdf4 | ||
|
|
132c88b3f0 | ||
|
|
665ec0a34f | ||
|
|
8207d1d2a9 | ||
|
|
9c995fc4dd | ||
|
|
e4373454ba | ||
|
|
445da233c0 | ||
|
|
b708bb8ab2 | ||
|
|
1cad73b9a7 | ||
|
|
2be92516c6 | ||
|
|
950cdb4efe | ||
|
|
7562b62241 | ||
|
|
4946982783 | ||
|
|
992e16ba05 | ||
|
|
b8d2b90370 | ||
|
|
292d5923cf | ||
|
|
5d80ec57b9 | ||
|
|
89236f13b5 | ||
|
|
957d499658 | ||
|
|
c18876471c | ||
|
|
4a12f13b96 | ||
|
|
6e8bca2ac4 | ||
|
|
5fa8455b21 | ||
|
|
944d6529b2 | ||
|
|
42fc408947 | ||
|
|
192b474c60 | ||
|
|
c8d7c4bba5 | ||
|
|
3592b9f95a | ||
|
|
0f8e0fec53 | ||
|
|
444d7e63fd | ||
|
|
ea00130d08 | ||
|
|
e1fc7b7b6f | ||
|
|
75e191f86b | ||
|
|
16cc0a3033 | ||
|
|
efaa3ce59e | ||
|
|
02b600dcbc | ||
|
|
6270ad41cc | ||
|
|
7a20ddb4ea | ||
|
|
e388408978 | ||
|
|
9135332777 | ||
|
|
9fe796f195 | ||
|
|
4d7444ef5b | ||
|
|
fceadd2009 | ||
|
|
116a348670 | ||
|
|
5e4dbc78ed | ||
|
|
8205924917 | ||
|
|
ea16bf98f4 | ||
|
|
86b27e690b | ||
|
|
70c04a2530 | ||
|
|
afd5856d0d | ||
|
|
cfd4666bb2 | ||
|
|
54932b1ac2 | ||
|
|
69fff32420 | ||
|
|
4ccd37aa5d | ||
|
|
4f304fb13a | ||
|
|
e3d38791d3 | ||
|
|
4b3f54d692 | ||
|
|
23b79f6411 | ||
|
|
80461ea7fe | ||
|
|
9df38c95a3 | ||
|
|
0b2462c82a | ||
|
|
bf8ed0d928 | ||
|
|
0f4ad67827 | ||
|
|
3b8d77b496 | ||
|
|
7b78d0b778 | ||
|
|
2e7cd7e8ed | ||
|
|
7e96976257 | ||
|
|
834c58c25a | ||
|
|
88316e7017 | ||
|
|
ca7de23a56 | ||
|
|
9176e877eb | ||
|
|
a5a92b111b | ||
|
|
0e8d9e87f6 | ||
|
|
5d44ee704c | ||
|
|
d0094e84bb | ||
|
|
d24c26b073 | ||
|
|
283eb54a74 | ||
|
|
fb44f3711e | ||
|
|
c7af7681a2 | ||
|
|
2efd5f2764 | ||
|
|
0537de0ec1 | ||
|
|
7de8955c4f | ||
|
|
bf279ca5ad | ||
|
|
4a77756533 | ||
|
|
01b40ca244 | ||
|
|
13e7dbc7fd | ||
|
|
060a3e48db | ||
|
|
433e375def | ||
|
|
c148cb8ce7 | ||
|
|
a73e1a7756 | ||
|
|
bf18812a3d | ||
|
|
e89b647dfa | ||
|
|
26814ff6f7 | ||
|
|
209ea38ecf | ||
|
|
01941ed2e4 | ||
|
|
6ce743c39d | ||
|
|
75df0e4308 | ||
|
|
c5d4c4f3e3 | ||
|
|
36bd9edbeb | ||
|
|
4ed95f5f4c | ||
|
|
40e7977cf5 | ||
|
|
eb2548f9a6 | ||
|
|
0fc0e1c21a | ||
|
|
81abc4acca | ||
|
|
27fe84f2a0 | ||
|
|
457d6e1f7c | ||
|
|
70f7337226 | ||
|
|
66ac79664b | ||
|
|
4314c06c5c | ||
|
|
4c52d9b039 | ||
|
|
e50dd17a00 | ||
|
|
2aeabe6b50 | ||
|
|
c2e9925162 | ||
|
|
75dcc0f1cb | ||
|
|
e68b127acc | ||
|
|
727ab19468 | ||
|
|
aa291320f5 | ||
|
|
f6117ddeb9 | ||
|
|
6955b6144b | ||
|
|
37fcb9fad9 | ||
|
|
debf77005f | ||
|
|
f699b26fa6 | ||
|
|
8ad8a5ea47 | ||
|
|
db316726fd |
166 changed files with 23000 additions and 988 deletions
16
.env.example
Normal file
16
.env.example
Normal file
|
|
@ -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
|
||||||
|
|
@ -12,35 +12,26 @@ permissions:
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: Test
|
name: Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
strategy:
|
container:
|
||||||
matrix:
|
image: docker.io/library/golang:1.25
|
||||||
go-version: ["1.25"]
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version: ${{ matrix.go-version }}
|
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test -v -race -count=1 ./...
|
run: go test -v -race -count=1 ./...
|
||||||
|
|
||||||
build:
|
build:
|
||||||
name: Build
|
name: Build
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker.io/library/golang:1.25
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
goos: [linux, darwin, windows]
|
goos: [linux, darwin, windows]
|
||||||
goarch: [amd64, arm64]
|
goarch: [amd64, arm64]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version: "1.25"
|
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
env:
|
env:
|
||||||
|
|
@ -50,30 +41,30 @@ jobs:
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
name: Lint
|
name: Lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker.io/library/golang:1.25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Install golangci-lint
|
||||||
uses: actions/setup-go@v6
|
run: |
|
||||||
with:
|
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \
|
||||||
go-version: "1.25"
|
| sh -s -- -b /usr/local/bin v2.11.4
|
||||||
|
|
||||||
- name: Run golangci-lint
|
- name: Run golangci-lint
|
||||||
uses: golangci/golangci-lint-action@v9
|
run: golangci-lint run ./...
|
||||||
with:
|
|
||||||
version: v2.11.4
|
|
||||||
|
|
||||||
coverage:
|
coverage:
|
||||||
name: Coverage
|
name: Coverage
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker.io/library/golang:1.25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
- name: Install python3
|
||||||
uses: actions/setup-go@v6
|
run: apt-get update && apt-get install -y --no-install-recommends python3
|
||||||
with:
|
|
||||||
go-version: "1.25"
|
|
||||||
|
|
||||||
- name: Run tests with coverage (all packages)
|
- name: Run tests with coverage (all packages)
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -102,24 +93,13 @@ jobs:
|
||||||
print('OK: Coverage meets minimum threshold')
|
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:
|
vet:
|
||||||
name: Vet
|
name: Vet
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: docker.io/library/golang:1.25
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Go
|
|
||||||
uses: actions/setup-go@v6
|
|
||||||
with:
|
|
||||||
go-version: "1.25"
|
|
||||||
|
|
||||||
- name: Run go vet
|
- name: Run go vet
|
||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
61
.forgejo/workflows/docker-rebuild.yml
Normal file
61
.forgejo/workflows/docker-rebuild.yml
Normal file
|
|
@ -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 \
|
||||||
|
.
|
||||||
118
.forgejo/workflows/release.yml
Normal file
118
.forgejo/workflows/release.yml
Normal file
|
|
@ -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 \
|
||||||
|
.
|
||||||
163
.github/workflows/release.yml
vendored
163
.github/workflows/release.yml
vendored
|
|
@ -1,163 +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 }}
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
|
|
||||||
|
|
||||||
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"
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -36,7 +36,12 @@ Thumbs.db
|
||||||
|
|
||||||
# GoReleaser
|
# GoReleaser
|
||||||
dist/
|
dist/
|
||||||
|
dist-ffbinaries/
|
||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
tmp/
|
tmp/
|
||||||
config/
|
config/
|
||||||
|
dist-ffbinaries/
|
||||||
|
|
||||||
|
# Claude Code: keep entirely local, do not track
|
||||||
|
.claude/
|
||||||
|
|
@ -2,6 +2,14 @@ version: 2
|
||||||
|
|
||||||
project_name: unarr
|
project_name: unarr
|
||||||
|
|
||||||
|
# Pre-build hook: fetch static ffmpeg + ffprobe per platform so each
|
||||||
|
# release tarball ships them adjacent to the unarr binary. ResolveFFmpeg /
|
||||||
|
# ResolveFFprobe pick them up via the "adjacent to executable" branch — no
|
||||||
|
# system install or runtime download needed.
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- bash scripts/download-ffmpeg-static.sh
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- main: ./cmd/unarr/
|
- main: ./cmd/unarr/
|
||||||
binary: unarr
|
binary: unarr
|
||||||
|
|
@ -18,17 +26,53 @@ builds:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
|
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
|
||||||
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
|
- -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.
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats: [tar.gz]
|
||||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: [zip]
|
||||||
|
files:
|
||||||
|
- LICENSE*
|
||||||
|
- README*
|
||||||
|
# Bundle the matching ffmpeg + ffprobe (filename includes .exe on Windows
|
||||||
|
# because download-ffmpeg-static.sh writes ffmpeg.exe / ffprobe.exe there).
|
||||||
|
- src: "dist-ffbinaries/{{ .Os }}-{{ .Arch }}/*"
|
||||||
|
dst: .
|
||||||
|
strip_parent: true
|
||||||
|
info:
|
||||||
|
mode: 0o755
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: "checksums.txt"
|
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:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
|
@ -37,6 +81,22 @@ changelog:
|
||||||
- "^test:"
|
- "^test:"
|
||||||
- "^chore:"
|
- "^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)
|
# Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
|
||||||
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
|
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
|
||||||
# brews:
|
# brews:
|
||||||
|
|
|
||||||
0
.nojekyll
Normal file
0
.nojekyll
Normal file
562
CHANGELOG.md
562
CHANGELOG.md
|
|
@ -5,12 +5,397 @@ 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/),
|
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).
|
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
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
|
||||||
|
- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **hls**: critico-driven hardening of fase 3.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **cors**: allow play from .to / staging / onion mirrors
|
||||||
|
- **library**: classify resolution by width + height, not height alone
|
||||||
|
- **transcode**: make preset libx264-only + restore quality opt-in
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.9.11
|
||||||
|
## [0.9.8] - 2026-05-27
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **upgrade**: break auto-apply restart loop (0.9.8)
|
||||||
|
## [0.9.7] - 2026-05-26
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
|
||||||
|
## [0.9.6] - 2026-05-26
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **daemon**: auto-apply upgrades when server signals (0.9.6)
|
||||||
|
## [0.9.5] - 2026-05-26
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
|
||||||
|
## [0.9.4] - 2026-05-26
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
|
||||||
|
## [0.9.3] - 2026-05-26
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **usenet**: warn at startup when par2 or extractor is missing
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **engine**: truncate errorMessage before reporting status
|
||||||
|
- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
|
||||||
|
## [0.9.2] - 2026-05-22
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
|
||||||
|
## [0.9.1] - 2026-05-21
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **mirror**: update fallback URLs to use IPFS and remove GitHub Pages
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **security**: bump golang.org/x deps and add container CVE scan gate
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.9.1
|
||||||
|
## [0.9.0] - 2026-05-21
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **agent**: add mirror failover, agent client refactor, status 401 detection
|
||||||
|
- **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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **security**: CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
|
||||||
|
- **security**: UPnP opt-in, bounded SSE reader, signed self-update
|
||||||
|
- **security**: harden HLS session IDs, /health disclosure, archive password handling
|
||||||
|
- **upgrade**: fetch releases from TorrentClaw app, not GitHub
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **config**: set default values for WebRTC and transcoding in minimal TOML config
|
||||||
|
- **transcode**: dynamic H.264 level + HW probe + capability reporting
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **streaming**: improve signal handling and remove unused components
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **self-update**: auto-restart live daemon after upgrade
|
||||||
|
- **streaming**: allow HLS sessions when webrtc disabled
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **gitignore**: add dist-ffbinaries to ignored files
|
||||||
|
- **release**: 0.8.1
|
||||||
|
## [0.8.0] - 2026-05-08
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **mediainfo**: ResolveFFmpeg + DownloadFFmpeg mirroring ffprobe pattern
|
||||||
|
- **release**: bundle ffmpeg + ffprobe in tarballs and Docker image
|
||||||
|
- **seed-file**: unarr-side handler for browser-on-demand seeding (Fase 4.7.c)
|
||||||
|
- **stream**: per-session quality cap from web
|
||||||
|
- **stream**: real-time transcoding for non-browser-decodable codecs
|
||||||
|
- **stream**: pion-based WebRTC byte streamer for browser playback
|
||||||
|
- **streaming**: seek-restart, single-session, idle sweeper, probe.json
|
||||||
|
- **streaming**: add HLS transport pipeline (daemon side)
|
||||||
|
- **streaming**: ffmpeg transcoding pipeline (direct play / fMP4 / HW accel)
|
||||||
|
- **torrent**: act as WebTorrent peer for browser ↔ unarr P2P streaming
|
||||||
|
- **wstracker-probe**: -seed FILE mode for browser ↔ unarr e2e validation
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **streaming**: bounded ffmpeg auto-restart + tmpdir gc + probe/stderr safety
|
||||||
|
- **transcoder**: force aac stereo 48khz + frag_duration for mse compat
|
||||||
|
- **transcoder**: force main profile + setparams Rec.709 + serveRange wait
|
||||||
|
- **transcoder**: correct scale filter + always force yuv420p
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.8.0
|
||||||
|
- **streaming**: post-review fixes — race lock, dead branch, stderr cap
|
||||||
|
- **torrent**: bump anacrolix log level Critical → Warning for visibility
|
||||||
|
## [0.7.0] - 2026-04-10
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **daemon**: enhance service management with start, stop, restart, and status commands for Windows
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.7.0
|
||||||
|
## [0.6.8] - 2026-04-10
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **library**: add server-driven file deletion with allow_delete config
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.6.8
|
||||||
|
## [0.6.7] - 2026-04-10
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **scan**: always scan downloads + organize dirs, deduplicate child paths
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.6.7
|
||||||
## [0.6.6] - 2026-04-09
|
## [0.6.6] - 2026-04-09
|
||||||
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **docker**: switch ffprobe download from johnvansickle.com to BtbN/FFmpeg-Builds
|
||||||
- **stream**: fix black screen on remote/Tailscale streaming
|
- **stream**: fix black screen on remote/Tailscale streaming
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **release**: 0.6.6
|
||||||
## [0.6.5] - 2026-04-09
|
## [0.6.5] - 2026-04-09
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -154,16 +539,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
## [0.4.1] - 2026-04-01
|
## [0.4.1] - 2026-04-01
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **cli**: add login command and refactor shared helpers
|
||||||
|
- **stream**: report watch progress to API via HTTP Range tracking
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ci**: fix lint errors and pin CI to Go 1.25
|
||||||
|
- **lint**: remove unused newStubCmd function
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- **cli**: remove moreseed stub command
|
||||||
|
- **cli**: remove redundant stub commands (monitor, open, add, compare)
|
||||||
|
## [0.4.0] - 2026-03-31
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **cli**: upgrade command, rich status, and version cache
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **progress**: always report status transitions and poll for control signals
|
||||||
|
## [0.3.7] - 2026-03-31
|
||||||
|
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- **docker**: remove dockerhub-description sync step
|
||||||
|
## [0.3.6] - 2026-03-31
|
||||||
|
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- **deps**: bump docker/metadata-action from 5 to 6
|
||||||
|
- **deps**: bump docker/setup-qemu-action from 3 to 4
|
||||||
|
- **deps**: bump docker/login-action from 3 to 4
|
||||||
|
- **deps**: bump docker/build-push-action from 6 to 7
|
||||||
|
- **deps**: bump codecov/codecov-action from 5 to 6
|
||||||
|
- **docker**: add Docker Hub description sync and DOCKERHUB.md
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
|
||||||
|
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
|
||||||
|
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
|
||||||
|
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
|
||||||
|
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
|
||||||
|
- **lint**: exclude common fire-and-forget patterns from errcheck
|
||||||
|
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
|
||||||
|
## [0.3.5] - 2026-03-30
|
||||||
|
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
|
||||||
|
## [0.3.3] - 2026-03-30
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ci**: remove go-client checkout steps
|
||||||
|
## [0.3.2] - 2026-03-30
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
|
||||||
|
- **release**: add Docker Hub publish and VirusTotal scan jobs
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- add beta notice, fix install URLs to get.torrentclaw.com
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **ci**: fix virustotal job condition syntax
|
||||||
|
- **docker**: simplify Dockerfile for CI builds (no local go-client)
|
||||||
|
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- re-enable homebrew tap in goreleaser
|
||||||
|
## [0.3.1] - 2026-03-30
|
||||||
|
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **build**: unused variable in Windows process check
|
||||||
|
- **release**: disable homebrew tap until repo is created
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
- rename module from torrentclaw-cli to unarr
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
- remove UPX compression (antivirus false positives, startup penalty)
|
||||||
|
## [0.3.0] - 2026-03-29
|
||||||
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **agent**: add WebSocket transport with HTTP fallback
|
- **agent**: add WebSocket transport with HTTP fallback
|
||||||
- **auth**: browser-based CLI authentication (like Claude Code)
|
- **auth**: browser-based CLI authentication (like Claude Code)
|
||||||
- **cli**: add login command and refactor shared helpers
|
|
||||||
- **cli**: upgrade command, rich status, and version cache
|
|
||||||
- **daemon**: add auto-scan, force start, and stall timeout default
|
- **daemon**: add auto-scan, force start, and stall timeout default
|
||||||
- **debrid**: add HTTPS downloader for debrid direct URLs
|
- **debrid**: add HTTPS downloader for debrid direct URLs
|
||||||
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
|
|
||||||
- **stream**: report watch progress to API via HTTP Range tracking
|
|
||||||
- **stream**: UPnP port forwarding for remote video playback
|
- **stream**: UPnP port forwarding for remote video playback
|
||||||
- **usenet**: implement full NNTP download pipeline
|
- **usenet**: implement full NNTP download pipeline
|
||||||
- add migrate command, media server detection, and debrid auto-config
|
- add migrate command, media server detection, and debrid auto-config
|
||||||
|
|
@ -173,61 +659,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- improve daemon resilience, streaming, and usenet downloads
|
- improve daemon resilience, streaming, and usenet downloads
|
||||||
- initial commit — unarr CLI
|
- initial commit — unarr CLI
|
||||||
|
|
||||||
### CI/CD
|
|
||||||
|
|
||||||
- **deps**: bump docker/metadata-action from 5 to 6
|
|
||||||
- **deps**: bump docker/setup-qemu-action from 3 to 4
|
|
||||||
- **deps**: bump docker/login-action from 3 to 4
|
|
||||||
- **deps**: bump docker/build-push-action from 6 to 7
|
|
||||||
- **deps**: bump codecov/codecov-action from 5 to 6
|
|
||||||
- **docker**: remove dockerhub-description sync step
|
|
||||||
- **docker**: add Docker Hub description sync and DOCKERHUB.md
|
|
||||||
- **release**: add Docker Hub publish and VirusTotal scan jobs
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
|
|
||||||
- extract BuildSyncItems to library package, remove duplication
|
- extract BuildSyncItems to library package, remove duplication
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
- add beta notice, fix install URLs to get.torrentclaw.com
|
|
||||||
- improve CLI help, shell completion, and README
|
- improve CLI help, shell completion, and README
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **build**: unused variable in Windows process check
|
|
||||||
- **ci**: fix lint errors and pin CI to Go 1.25
|
|
||||||
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
|
|
||||||
- **ci**: remove go-client checkout steps
|
|
||||||
- **ci**: fix virustotal job condition syntax
|
|
||||||
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
|
|
||||||
- **docker**: simplify Dockerfile for CI builds (no local go-client)
|
|
||||||
- **lint**: remove unused newStubCmd function
|
|
||||||
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
|
|
||||||
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
|
|
||||||
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
|
|
||||||
- **lint**: exclude common fire-and-forget patterns from errcheck
|
|
||||||
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
|
|
||||||
- **progress**: always report status transitions and poll for control signals
|
|
||||||
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
|
|
||||||
- **release**: disable homebrew tap until repo is created
|
|
||||||
- **torrent**: expand tracker list, add DHT persistence and configurable timeouts
|
- **torrent**: expand tracker list, add DHT persistence and configurable timeouts
|
||||||
- force-start tasks bypass HasCapacity check in dispatch loop
|
- force-start tasks bypass HasCapacity check in dispatch loop
|
||||||
- add panic recovery to auto-scan, cap DHT nodes at 200
|
- add panic recovery to auto-scan, cap DHT nodes at 200
|
||||||
- harden usenet/debrid downloaders from critico review
|
- harden usenet/debrid downloaders from critico review
|
||||||
|
|
||||||
### Other
|
|
||||||
|
|
||||||
- **cli**: remove moreseed stub command
|
|
||||||
- **cli**: remove redundant stub commands (monitor, open, add, compare)
|
|
||||||
- re-enable homebrew tap in goreleaser
|
|
||||||
- rename module from torrentclaw-cli to unarr
|
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
- remove UPX compression (antivirus false positives, startup penalty)
|
|
||||||
- add -s -w -trimpath to Makefile, add build-small target with UPX
|
- 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
|
||||||
|
[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
|
||||||
|
[0.8.0]: https://github.com/torrentclaw/unarr/compare/v0.7.0...v0.8.0
|
||||||
|
[0.7.0]: https://github.com/torrentclaw/unarr/compare/v0.6.8...v0.7.0
|
||||||
|
[0.6.8]: https://github.com/torrentclaw/unarr/compare/v0.6.7...v0.6.8
|
||||||
|
[0.6.7]: https://github.com/torrentclaw/unarr/compare/v0.6.6...v0.6.7
|
||||||
[0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6
|
[0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6
|
||||||
[0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5
|
[0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5
|
||||||
[0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4
|
[0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4
|
||||||
|
|
@ -242,4 +714,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
[0.5.1]: https://github.com/torrentclaw/unarr/compare/v0.5.0...v0.5.1
|
[0.5.1]: https://github.com/torrentclaw/unarr/compare/v0.5.0...v0.5.1
|
||||||
[0.5.0]: https://github.com/torrentclaw/unarr/compare/v0.4.1...v0.5.0
|
[0.5.0]: https://github.com/torrentclaw/unarr/compare/v0.4.1...v0.5.0
|
||||||
[0.4.1]: https://github.com/torrentclaw/unarr/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://github.com/torrentclaw/unarr/compare/v0.4.0...v0.4.1
|
||||||
|
[0.4.0]: https://github.com/torrentclaw/unarr/compare/v0.3.7...v0.4.0
|
||||||
|
[0.3.7]: https://github.com/torrentclaw/unarr/compare/v0.3.6...v0.3.7
|
||||||
|
[0.3.6]: https://github.com/torrentclaw/unarr/compare/v0.3.5...v0.3.6
|
||||||
|
[0.3.5]: https://github.com/torrentclaw/unarr/compare/v0.3.3...v0.3.5
|
||||||
|
[0.3.3]: https://github.com/torrentclaw/unarr/compare/v0.3.2...v0.3.3
|
||||||
|
[0.3.2]: https://github.com/torrentclaw/unarr/compare/v0.3.1...v0.3.2
|
||||||
|
[0.3.1]: https://github.com/torrentclaw/unarr/compare/v0.3.0...v0.3.1
|
||||||
|
[0.3.0]: https://github.com/torrentclaw/unarr/releases/tag/v0.3.0
|
||||||
|
|
||||||
|
|
|
||||||
1
CNAME
Normal file
1
CNAME
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
unarr.torrentclaw.com
|
||||||
126
DOCKERHUB.md
126
DOCKERHUB.md
|
|
@ -1,12 +1,21 @@
|
||||||
# unarr
|
# unarr
|
||||||
|
|
||||||
Powerful terminal tool for torrent search and management. Search 30+ sources, inspect quality, discover popular content, find streaming providers, and manage downloads — all from your terminal.
|
**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.
|
||||||
|
|
||||||
**[GitHub](https://github.com/torrentclaw/unarr)** | **[Documentation](https://github.com/torrentclaw/unarr#readme)** | **[Releases](https://github.com/torrentclaw/unarr/releases)**
|
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
|
||||||
|
|
||||||
## Quick Start
|
> Powered by [TorrentClaw](https://torrentclaw.com) — an aggregator that unifies
|
||||||
|
> YTS, EZTV, Knaben, Torrentio, Bitmagnet and more, enriched with TMDB metadata
|
||||||
|
> and a 0–100 quality score per release.
|
||||||
|
|
||||||
### 1. Setup (interactive wizard)
|
---
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
### 1. First-time setup (interactive wizard)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -it --rm \
|
docker run -it --rm \
|
||||||
|
|
@ -14,6 +23,9 @@ docker run -it --rm \
|
||||||
torrentclaw/unarr setup
|
torrentclaw/unarr setup
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The wizard asks for your TorrentClaw API key (free at
|
||||||
|
[torrentclaw.com](https://torrentclaw.com)) and your download directory.
|
||||||
|
|
||||||
### 2. Run the daemon
|
### 2. Run the daemon
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -26,6 +38,10 @@ docker run -d --name unarr \
|
||||||
torrentclaw/unarr
|
torrentclaw/unarr
|
||||||
```
|
```
|
||||||
|
|
||||||
|
That's it — `unarr` now runs headless, watching for jobs and managing downloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Docker Compose
|
## Docker Compose
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
|
|
@ -45,45 +61,54 @@ services:
|
||||||
environment:
|
environment:
|
||||||
- TZ=UTC
|
- TZ=UTC
|
||||||
# - UNARR_API_KEY=tc_your_key_here
|
# - UNARR_API_KEY=tc_your_key_here
|
||||||
|
network_mode: host # recommended for full P2P performance
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 512M
|
memory: 512M
|
||||||
cpus: "2.0"
|
cpus: "2.0"
|
||||||
network_mode: host
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
unarr-data:
|
unarr-data:
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose run --rm unarr setup # one-time wizard
|
||||||
|
docker compose up -d # start the daemon
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Volumes
|
## Volumes
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
|------|---------|
|
|--------------|--------------------------------------------------|
|
||||||
| `/config` | Configuration file (`config.toml`) |
|
| `/config` | Configuration file (`config.toml`) |
|
||||||
| `/downloads` | Finished media downloads |
|
| `/downloads` | Finished media downloads |
|
||||||
| `/data` | Internal state: torrent metadata, cache |
|
| `/data` | Internal state: torrent metadata, cache |
|
||||||
|
|
||||||
## Environment Variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|------------------------|--------------------------------------|---------------------------|
|
||||||
| `TZ` | Timezone | `UTC` |
|
|
||||||
| `UNARR_API_KEY` | TorrentClaw API key | from config |
|
| `UNARR_API_KEY` | TorrentClaw API key | from config |
|
||||||
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
|
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
|
||||||
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
|
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
|
||||||
| `UNARR_CONFIG_DIR` | Config directory | `/config` |
|
| `UNARR_CONFIG_DIR` | Config directory | `/config` |
|
||||||
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
|
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
|
||||||
|
| `TZ` | Timezone | `UTC` |
|
||||||
|
|
||||||
|
Any config value can be overridden by its matching `UNARR_*` environment variable.
|
||||||
|
|
||||||
## Networking
|
## Networking
|
||||||
|
|
||||||
**Host mode** (recommended) gives full P2P performance with no port management:
|
**Host mode (recommended)** — full P2P performance, no port mapping:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
network_mode: host
|
network_mode: host
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bridge mode** — more isolated, but requires explicit ports:
|
**Bridge mode** — more isolated, but you must expose the BitTorrent ports:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
ports:
|
ports:
|
||||||
|
|
@ -91,7 +116,7 @@ ports:
|
||||||
- "6881-6889:6881-6889/udp"
|
- "6881-6889:6881-6889/udp"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Commands
|
## Running commands
|
||||||
|
|
||||||
Use `docker exec` for one-off commands while the daemon is running:
|
Use `docker exec` for one-off commands while the daemon is running:
|
||||||
|
|
||||||
|
|
@ -99,32 +124,77 @@ Use `docker exec` for one-off commands while the daemon is running:
|
||||||
docker exec unarr unarr search "inception" --quality 1080p
|
docker exec unarr unarr search "inception" --quality 1080p
|
||||||
docker exec unarr unarr popular --limit 10
|
docker exec unarr unarr popular --limit 10
|
||||||
docker exec unarr unarr status
|
docker exec unarr unarr status
|
||||||
docker exec unarr unarr doctor
|
docker exec unarr unarr doctor # diagnose config / connectivity
|
||||||
```
|
```
|
||||||
|
|
||||||
## Supported Architectures
|
---
|
||||||
|
|
||||||
| Architecture | Tag |
|
|
||||||
|-------------|-----|
|
|
||||||
| `linux/amd64` | `latest`, `0.3`, `0.3.5` |
|
|
||||||
| `linux/arm64` | `latest`, `0.3`, `0.3.5` |
|
|
||||||
|
|
||||||
## Tags
|
## Tags
|
||||||
|
|
||||||
| Tag | Description |
|
| Tag | Description |
|
||||||
|-----|-------------|
|
|----------|--------------------------------------------------|
|
||||||
| `latest` | Latest stable release |
|
| `latest` | Latest stable release |
|
||||||
| `X.Y.Z` | Specific version (e.g. `0.3.5`) |
|
| `X.Y.Z` | Exact version (e.g. `0.9.0`) |
|
||||||
| `X.Y` | Latest patch for minor version (e.g. `0.3`) |
|
| `X.Y` | Latest patch within a minor (e.g. `0.9`) |
|
||||||
|
|
||||||
## Image Details
|
Pin a tag in production (`torrentclaw/unarr:0.9.0`) for reproducible deploys.
|
||||||
|
|
||||||
- **Base image:** Alpine 3.22
|
## Supported architectures
|
||||||
- **User:** `unarr` (UID 1000, GID 1000)
|
|
||||||
|
Multi-arch image — Docker pulls the right one automatically:
|
||||||
|
|
||||||
|
- `linux/amd64`
|
||||||
|
- `linux/arm64` (Apple Silicon, Raspberry Pi 4/5, ARM servers)
|
||||||
|
|
||||||
|
## Image details
|
||||||
|
|
||||||
|
- **Base:** Alpine 3.22 (minimal, regularly patched)
|
||||||
|
- **User:** `unarr` (UID 1000, GID 1000) — runs as **non-root**
|
||||||
- **Entrypoint:** `unarr start` (daemon mode)
|
- **Entrypoint:** `unarr start` (daemon mode)
|
||||||
- **Read-only filesystem** — only mounted volumes are writable
|
- **Read-only rootfs** — only mounted volumes are writable
|
||||||
- **No root required** — runs as non-root by default
|
- **Bundled `ffmpeg` / `ffprobe`** for media inspection — nothing else to install
|
||||||
|
- **Self-contained updates** — binaries are served from TorrentClaw's own
|
||||||
|
infrastructure, no third-party registry dependency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Other install methods
|
||||||
|
|
||||||
|
Not using Docker? Install the native binary instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux / macOS
|
||||||
|
curl -fsSL https://torrentclaw.com/install.sh | sh
|
||||||
|
|
||||||
|
# Windows (PowerShell)
|
||||||
|
irm https://torrentclaw.com/install.ps1 | iex
|
||||||
|
|
||||||
|
# Go toolchain
|
||||||
|
go install github.com/torrentclaw/unarr/cmd/unarr@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mirrors
|
||||||
|
|
||||||
|
The installer and release binaries are served from every TorrentClaw mirror, so
|
||||||
|
you can install even if one domain is blocked in your region. Each mirror is
|
||||||
|
self-contained (it serves its own binaries — no cross-domain dependency):
|
||||||
|
|
||||||
|
| Mirror | Install command |
|
||||||
|
|--------|-----------------|
|
||||||
|
| `torrentclaw.com` (primary) | `curl -fsSL https://torrentclaw.com/install.sh \| sh` |
|
||||||
|
| `torrentclaw.to` | `curl -fsSL https://torrentclaw.to/install.sh \| sh` |
|
||||||
|
| Tor (`.onion`) | `torsocks sh -c "$(curl http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion/install.sh)"` |
|
||||||
|
|
||||||
|
The Tor address routes everything (install script + binaries) through the hidden
|
||||||
|
service, so no clearnet exit is needed.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- **Website & docs:** https://torrentclaw.com/unarr
|
||||||
|
- **CLI install guide:** https://torrentclaw.com/cli
|
||||||
|
- **API & account:** https://torrentclaw.com
|
||||||
|
- **Mirror status:** https://torrentclaw.com/mirrors
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License — see [LICENSE](https://github.com/torrentclaw/unarr/blob/main/LICENSE) for details.
|
MIT.
|
||||||
|
|
|
||||||
108
Dockerfile
108
Dockerfile
|
|
@ -1,27 +1,8 @@
|
||||||
# ---- ffprobe static binary stage ----
|
|
||||||
# Download a static ffprobe build from BtbN/FFmpeg-Builds (GitHub CDN, reliable).
|
|
||||||
FROM alpine:3.22 AS ffprobe-dl
|
|
||||||
|
|
||||||
RUN apk add --no-cache curl xz
|
|
||||||
|
|
||||||
RUN ARCH=$(uname -m) && \
|
|
||||||
case "$ARCH" in \
|
|
||||||
x86_64) SLUG="linux64" ;; \
|
|
||||||
aarch64) SLUG="linuxarm64" ;; \
|
|
||||||
*) echo "Unsupported arch: $ARCH" && exit 1 ;; \
|
|
||||||
esac && \
|
|
||||||
curl -fsSL --retry 3 --retry-delay 5 \
|
|
||||||
"https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${SLUG}-gpl.tar.xz" \
|
|
||||||
-o /tmp/ff.tar.xz && \
|
|
||||||
mkdir /tmp/ffbuild && \
|
|
||||||
tar xJ -f /tmp/ff.tar.xz --strip-components=1 -C /tmp/ffbuild/ && \
|
|
||||||
mv /tmp/ffbuild/bin/ffprobe /usr/local/bin/ffprobe && \
|
|
||||||
chmod +x /usr/local/bin/ffprobe && \
|
|
||||||
rm -rf /tmp/ff.tar.xz /tmp/ffbuild && \
|
|
||||||
ffprobe -version | head -1
|
|
||||||
|
|
||||||
# ---- Build stage ----
|
# ---- 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
|
RUN apk add --no-cache git ca-certificates
|
||||||
|
|
||||||
|
|
@ -35,16 +16,69 @@ RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
ARG VERSION=dev
|
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 ----
|
# ---- 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
|
||||||
|
|
||||||
RUN apk upgrade --no-cache && \
|
# par2 → repair corrupted Usenet segments (without it a single bad segment
|
||||||
apk add --no-cache ca-certificates tzdata
|
# 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.
|
||||||
|
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" && \
|
||||||
|
chmod +x /usr/local/bin/cloudflared
|
||||||
|
|
||||||
# Non-root user (UID 1000 matches typical host user for volume permissions)
|
# 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
|
# Default directories
|
||||||
RUN mkdir -p /config /downloads /data && \
|
RUN mkdir -p /config /downloads /data && \
|
||||||
|
|
@ -53,13 +87,29 @@ RUN mkdir -p /config /downloads /data && \
|
||||||
USER unarr
|
USER unarr
|
||||||
|
|
||||||
COPY --from=builder /unarr /usr/local/bin/unarr
|
COPY --from=builder /unarr /usr/local/bin/unarr
|
||||||
COPY --from=ffprobe-dl /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
|
||||||
|
|
||||||
# Environment: point config/data to container paths
|
# Environment: point config/data to container paths
|
||||||
ENV UNARR_CONFIG_DIR=/config
|
ENV UNARR_CONFIG_DIR=/config
|
||||||
ENV UNARR_DOWNLOAD_DIR=/downloads
|
ENV UNARR_DOWNLOAD_DIR=/downloads
|
||||||
ENV XDG_DATA_HOME=/data
|
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"]
|
VOLUME ["/config", "/downloads", "/data"]
|
||||||
|
|
||||||
ENTRYPOINT ["unarr"]
|
ENTRYPOINT ["unarr"]
|
||||||
|
|
|
||||||
170
Docs/plans/library-sync.md
Normal file
170
Docs/plans/library-sync.md
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
# Plan: Sincronización bidireccional de biblioteca (CLI ↔ Web)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
La biblioteca web solo muestra descargas completadas (download_task + debrid). El `unarr scan` escanea ficheros con ffprobe y los sube al servidor, pero solo soporta un path, no detecta borrados del disco, y no permite borrar ficheros desde la web. El usuario quiere una biblioteca unificada que refleje el estado real de su colección y se sincronice en ambas direcciones.
|
||||||
|
|
||||||
|
## Protocolo de sincronización
|
||||||
|
|
||||||
|
### Forward Sync (Disco → Web)
|
||||||
|
1. CLI escanea todos los `ScanPaths` configurados
|
||||||
|
2. Para cada path: descubre ficheros, compara con cache (skip ffprobe si no cambió), sube a `/library-sync`
|
||||||
|
3. En `isLastBatch=true`: el servidor elimina items con ese `scanPath` que no estén en el batch (ficheros borrados del disco desaparecen de la web)
|
||||||
|
|
||||||
|
### Reverse Sync (Web → Disco)
|
||||||
|
1. CLI llama a `GET /agent/library-deletions` — items que el usuario soft-deleted desde la web
|
||||||
|
2. Si `AutoDelete=true` o `--yes`: borra ficheros del disco
|
||||||
|
3. Si no: muestra lista y pide confirmación interactiva
|
||||||
|
4. Llama a `POST /agent/library-deletions/confirm` con los IDs confirmados → hard-delete en DB
|
||||||
|
|
||||||
|
### Resolución de conflictos
|
||||||
|
- Fichero en disco pero no en web → forward sync lo añade
|
||||||
|
- Fichero en web pero no en disco → forward sync lo elimina (isLastBatch)
|
||||||
|
- Soft-deleted en web, aún en disco → reverse sync lo borra del disco y confirma
|
||||||
|
- Soft-deleted en web, ya borrado del disco → reverse sync confirma directamente
|
||||||
|
- Race condition (user borra en web mientras CLI escanea) → forward sync skippea rows con `deleted_at IS NOT NULL`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 1: Multi-path + Forward Sync mejorado
|
||||||
|
|
||||||
|
### 1.1 CLI — Config multi-path
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/config/config.go`
|
||||||
|
- Añadir `ScanPaths []string` a `LibraryConfig`
|
||||||
|
- Migrar `ScanPath` → `ScanPaths[0]` en `Load()` si `ScanPaths` está vacío
|
||||||
|
- Añadir `AutoDelete bool` (default false)
|
||||||
|
|
||||||
|
### 1.2 CLI — Cache v2
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/library/types.go`
|
||||||
|
- Cambiar `LibraryCache` a version 2: `Paths map[string][]LibraryItem`
|
||||||
|
- Migración v1→v2: `Path`+items → `Paths[Path]`
|
||||||
|
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/library/cache.go`
|
||||||
|
- `LoadCache` detecta versión y migra
|
||||||
|
- `SaveCache` siempre guarda v2
|
||||||
|
|
||||||
|
### 1.3 CLI — Scan multi-path
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/cmd/scan.go`
|
||||||
|
- `unarr scan` sin args → escanea todos los `ScanPaths`
|
||||||
|
- `unarr scan /path/a /path/b` → escanea paths específicos y los recuerda en config
|
||||||
|
- Loop: para cada path, scan + sync con su `scanPath`
|
||||||
|
|
||||||
|
### 1.4 CLI — Nuevo comando `unarr sync`
|
||||||
|
**Archivo nuevo:** `torrentclaw-cli/internal/cmd/sync.go`
|
||||||
|
- Forward sync: scan ligero (sin ffprobe para ficheros sin cambios) + upload
|
||||||
|
- Sin reverse sync todavía (Fase 3)
|
||||||
|
- Flags: `--dry-run`, `--paths`
|
||||||
|
|
||||||
|
### 1.5 Web — Columna `scan_path` en `library_item`
|
||||||
|
**Archivo:** `torrentclaw-web/src/lib/db/schema.ts`
|
||||||
|
- Añadir `scanPath: varchar(2048)` a tabla `libraryItem`
|
||||||
|
- Generar migración con `pnpm db:generate`
|
||||||
|
|
||||||
|
**Archivo:** `torrentclaw-web/src/lib/services/library-upgrade.ts`
|
||||||
|
- `syncLibraryItems()`: persistir `scanPath` en cada row al hacer upsert
|
||||||
|
|
||||||
|
### 1.6 CLI — Daemon multi-path
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/cmd/daemon.go`
|
||||||
|
- `runAutoScan()` itera sobre todos los `ScanPaths`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 2: Reverse Sync (Web → Disco)
|
||||||
|
|
||||||
|
### 2.1 Web — Soft-delete
|
||||||
|
**Archivo:** `torrentclaw-web/src/lib/db/schema.ts`
|
||||||
|
- Añadir `deletedAt: timestamp` a tabla `libraryItem`
|
||||||
|
- Generar migración
|
||||||
|
|
||||||
|
### 2.2 Web — Endpoints de borrado
|
||||||
|
**Archivo nuevo:** `torrentclaw-web/src/app/api/internal/library/items/route.ts`
|
||||||
|
- `DELETE` — session auth, recibe `{itemIds: number[]}`, hace soft-delete (`deletedAt = NOW()`)
|
||||||
|
|
||||||
|
**Archivo nuevo:** `torrentclaw-web/src/app/api/internal/agent/library-deletions/route.ts`
|
||||||
|
- `GET` — agent auth, devuelve items con `deletedAt IS NOT NULL` para ese usuario
|
||||||
|
- `POST` — agent auth, recibe `{confirmedIds: number[]}`, hard-delete los rows
|
||||||
|
|
||||||
|
### 2.3 Web — Heartbeat con pendingDeletions
|
||||||
|
**Archivo:** endpoint de heartbeat del agente
|
||||||
|
- Añadir `pendingDeletions: number` al response (count de items con `deletedAt IS NOT NULL`)
|
||||||
|
|
||||||
|
### 2.4 Web — Forward sync respeta soft-deletes
|
||||||
|
**Archivo:** `torrentclaw-web/src/lib/services/library-upgrade.ts`
|
||||||
|
- `syncLibraryItems()` en `isLastBatch`: la query de DELETE excluye rows con `deletedAt IS NOT NULL`
|
||||||
|
|
||||||
|
### 2.5 CLI — Agent client nuevos métodos
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/agent/client.go`
|
||||||
|
- `GetLibraryDeletions(ctx) → []DeletionItem`
|
||||||
|
- `ConfirmLibraryDeletions(ctx, ids []int) → error`
|
||||||
|
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/agent/types.go`
|
||||||
|
- `DeletionItem {ID int, FilePath string, DeletedAt string}`
|
||||||
|
|
||||||
|
### 2.6 CLI — Sync reverse
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/cmd/sync.go`
|
||||||
|
- Después del forward sync: llama a `GetLibraryDeletions()`
|
||||||
|
- Valida que cada fichero está dentro de un `ScanPaths` conocido (seguridad)
|
||||||
|
- Si `AutoDelete` o `--yes`: borra y confirma
|
||||||
|
- Si no: muestra lista interactiva, pide confirmación
|
||||||
|
- Flag `--no-delete` para skip reverse sync
|
||||||
|
- Si `BackupDir` configurado: mover a backup en vez de borrar
|
||||||
|
|
||||||
|
### 2.7 CLI — Daemon auto-delete
|
||||||
|
**Archivo:** `torrentclaw-cli/internal/cmd/daemon.go`
|
||||||
|
- Al final de `runAutoSync()`: si `AutoDelete=true`, procesa deletions automáticamente
|
||||||
|
- Si no: log warning "N files pending deletion, run `unarr sync`"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fase 3: Web UI (brief)
|
||||||
|
|
||||||
|
- Botón "Eliminar" en items de biblioteca → llama `DELETE /library/items`
|
||||||
|
- Badge "Pendiente de borrar" en items soft-deleted
|
||||||
|
- Posibilidad de cancelar el borrado (clear `deletedAt`)
|
||||||
|
- Vista unificada: scanned items + downloaded items en la misma vista
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Archivos clave
|
||||||
|
|
||||||
|
### CLI (Go)
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---------|--------|
|
||||||
|
| `internal/config/config.go` | ScanPaths, AutoDelete, migración |
|
||||||
|
| `internal/library/types.go` | Cache v2 con Paths map |
|
||||||
|
| `internal/library/cache.go` | Load/Save v2, migración v1 |
|
||||||
|
| `internal/library/sync.go` | BuildSyncItems (sin cambios) |
|
||||||
|
| `internal/cmd/scan.go` | Multi-path loop |
|
||||||
|
| `internal/cmd/sync.go` | **Nuevo** — comando sync bidireccional |
|
||||||
|
| `internal/cmd/daemon.go` | runAutoSync multi-path + reverse |
|
||||||
|
| `internal/agent/client.go` | GetLibraryDeletions, ConfirmLibraryDeletions |
|
||||||
|
| `internal/agent/types.go` | DeletionItem type |
|
||||||
|
|
||||||
|
### Web (TypeScript)
|
||||||
|
| Archivo | Cambio |
|
||||||
|
|---------|--------|
|
||||||
|
| `src/lib/db/schema.ts` | scanPath + deletedAt en library_item |
|
||||||
|
| `src/lib/services/library-upgrade.ts` | persistir scanPath, respetar soft-deletes |
|
||||||
|
| `src/app/api/internal/agent/library-deletions/route.ts` | **Nuevo** — GET + POST |
|
||||||
|
| `src/app/api/internal/library/items/route.ts` | **Nuevo** — DELETE soft-delete |
|
||||||
|
| Endpoint heartbeat del agente | pendingDeletions en response |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
### Fase 1
|
||||||
|
1. `go build ./cmd/unarr/ && go test ./...`
|
||||||
|
2. Configurar 2 scan paths en config.toml, ejecutar `unarr scan` → ambos se escanean
|
||||||
|
3. Borrar un fichero del disco, ejecutar `unarr scan` → desaparece de la web
|
||||||
|
4. `pnpm build` en torrentclaw-web para verificar tipos
|
||||||
|
|
||||||
|
### Fase 2
|
||||||
|
1. Desde la web: borrar un item de la biblioteca
|
||||||
|
2. Ejecutar `unarr sync` → muestra el fichero pendiente de borrar, pedir confirmación
|
||||||
|
3. Confirmar → fichero se borra del disco y desaparece de la web
|
||||||
|
4. `unarr sync --dry-run` → muestra lo que haría sin hacer nada
|
||||||
|
5. Con `auto_delete = true` en config: el daemon borra automáticamente
|
||||||
|
|
||||||
|
### Fase 3
|
||||||
|
1. Verificar visualmente en Chrome DevTools la UI de borrado
|
||||||
|
2. Verificar que el badge "pendiente" aparece y desaparece correctamente
|
||||||
131
Docs/plans/security-stream-token.md
Normal file
131
Docs/plans/security-stream-token.md
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
# Phase 2.2 — Per-task stream token (deferred)
|
||||||
|
|
||||||
|
Status: deferred. Requires coordinated change in the web app
|
||||||
|
(`torrentclaw-web`) and the CLI daemon. Pulled out of the Phase 2
|
||||||
|
security pass because the CLI-only fixes (UPnP opt-in, SSE caps,
|
||||||
|
signed self-update) ship without web-side work; the stream-token
|
||||||
|
work cannot.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
`/stream`, `/playlist.m3u` and `/hls/<sessionID>/...` on the daemon
|
||||||
|
HTTP server have no authentication. Today, anyone who can reach the
|
||||||
|
listener and guesses (or learns) the `taskID` (for `/stream`) or
|
||||||
|
`sessionID` (for `/hls`) can fetch the active file.
|
||||||
|
|
||||||
|
Mitigations already in place after Phase 1+2:
|
||||||
|
|
||||||
|
- `sessionID` is restricted to a safe regex and is a server-issued
|
||||||
|
UUID v4 (122-bit entropy, not enumerable in practice).
|
||||||
|
- `/health` no longer leaks the active filename, taskID prefix or
|
||||||
|
client IP to remote callers (loopback diagnostics preserved).
|
||||||
|
- UPnP is opt-in, so by default the daemon is not exposed to the
|
||||||
|
public internet.
|
||||||
|
- The web client probes `/health` to pick LAN vs Tailscale.
|
||||||
|
|
||||||
|
Residual risk:
|
||||||
|
|
||||||
|
- On a shared LAN (open Wi-Fi, office network, dorm) any device can
|
||||||
|
reach the listener and brute-force `?id=<taskID>` against
|
||||||
|
`/stream`. taskIDs are also UUIDs, so this is high entropy, but
|
||||||
|
the URL may leak through browser history, sharing, screen capture
|
||||||
|
or a passive logger and there is no second factor.
|
||||||
|
- A user who explicitly opts into UPnP exposes the same surface to
|
||||||
|
the entire internet.
|
||||||
|
|
||||||
|
A per-task secret carried in the URL closes this without breaking
|
||||||
|
the `<video src>` flow (the browser cannot attach `Authorization`
|
||||||
|
headers to media elements, but it can append a query parameter).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Both ends agree on a per-task secret token. The web generates it
|
||||||
|
when the user requests streaming; the daemon receives the
|
||||||
|
`(taskID, token)` pair and validates the token on every `/stream`
|
||||||
|
and `/hls/...` request.
|
||||||
|
|
||||||
|
### Web side (`torrentclaw-web`)
|
||||||
|
|
||||||
|
When the user clicks "Stream":
|
||||||
|
|
||||||
|
1. Generate `streamToken = crypto.randomBytes(32).toString("hex")`
|
||||||
|
server-side (NOT browser, so it never lives in client storage
|
||||||
|
longer than the page lifetime).
|
||||||
|
2. Persist `(taskID, streamToken, expiresAt)` in `download_task`
|
||||||
|
(new columns or a sibling table). Token expires e.g. 6 h after
|
||||||
|
issue or on explicit revoke.
|
||||||
|
3. Push the token to the daemon over the existing heartbeat / sync
|
||||||
|
channel that already carries `streamRequested`. Add a
|
||||||
|
`streamToken` field next to it. The daemon trusts that channel
|
||||||
|
(it is authenticated agent ↔ origin).
|
||||||
|
4. Include the token in the stream URLs the API returns to the
|
||||||
|
browser:
|
||||||
|
`http://<host>:<port>/stream?id=<taskID>&t=<streamToken>` and
|
||||||
|
the `/hls/<sessionID>` URLs gain `?t=<streamToken>` too.
|
||||||
|
|
||||||
|
Files that will need to change:
|
||||||
|
|
||||||
|
- `src/lib/services/agent.ts` — extend the stream-request payload
|
||||||
|
with `streamToken`.
|
||||||
|
- `src/lib/db/schema.ts` — column / table for the token.
|
||||||
|
- `src/lib/services/stream-resolve.ts` — append `&t=` to the URLs
|
||||||
|
it builds.
|
||||||
|
- `src/lib/stream-probe.ts` — keep probing `/health` (no token),
|
||||||
|
then append `&t=` to the winning stream URL before returning.
|
||||||
|
- `src/middleware.ts` — no CORS change required (browser still hits
|
||||||
|
daemon directly).
|
||||||
|
|
||||||
|
### CLI side
|
||||||
|
|
||||||
|
- `internal/agent/types.go` / `internal/agent/sync.go` — accept and
|
||||||
|
store `streamToken` next to `streamRequested`.
|
||||||
|
- `internal/agent/daemon.go` — when the heartbeat reports a new
|
||||||
|
active stream task, push the token into the stream server via a
|
||||||
|
setter: `streamSrv.SetTaskToken(taskID, token)`.
|
||||||
|
- `internal/engine/stream_server.go`:
|
||||||
|
- New field `tokens map[string]string` guarded by mutex.
|
||||||
|
- `SetTaskToken(taskID, token)` and `ClearTaskToken(taskID)`.
|
||||||
|
- `handler` (`/stream`) extracts `?id=` and `?t=`, checks the
|
||||||
|
token with `subtle.ConstantTimeCompare`; 404 on mismatch.
|
||||||
|
- `hlsHandler` (`/hls/<sessionID>/...`) needs an HLS-session
|
||||||
|
→ token mapping, since the path carries `sessionID` not
|
||||||
|
`taskID`. Store the token on the `HLSSession` at start and
|
||||||
|
validate per request.
|
||||||
|
|
||||||
|
### Backwards compatibility
|
||||||
|
|
||||||
|
- The daemon must accept token-less requests for one minor version
|
||||||
|
so a newer daemon can still serve an older web (and vice-versa).
|
||||||
|
Gate the check on a config flag (`require_stream_token`,
|
||||||
|
default false in the first release, default true in the next).
|
||||||
|
- The `<video src>` form supports query parameters, so the only
|
||||||
|
user-visible change is the URL string.
|
||||||
|
|
||||||
|
## Open questions to resolve before implementing
|
||||||
|
|
||||||
|
1. Token TTL. 6 h gives plenty of room for a movie + pause +
|
||||||
|
resume; longer means the post-leak window is wider.
|
||||||
|
2. Where to store the token in `download_task` — same row, or a
|
||||||
|
sibling `download_stream_token` table that we can rotate
|
||||||
|
without writing to the task row.
|
||||||
|
3. Should `/playlist.m3u` (VLC) embed the token directly, or use
|
||||||
|
a one-shot redeem URL? VLC URL ends up in history.
|
||||||
|
4. Token reuse across HLS reconnects — yes, scoped to the
|
||||||
|
`HLSSession`, invalidated on `Close()`.
|
||||||
|
5. Do we want a daemon flag `--require-stream-token` independent
|
||||||
|
of config, for users to flip on quickly without editing TOML?
|
||||||
|
|
||||||
|
## Effort estimate
|
||||||
|
|
||||||
|
- CLI: ~3 h
|
||||||
|
- Web: ~3 h
|
||||||
|
- Migration + rollout (config flag flip): 1 release cycle of soak.
|
||||||
|
|
||||||
|
## Why not now
|
||||||
|
|
||||||
|
- Cross-repo coordination raises commit blast radius beyond what
|
||||||
|
the Phase 2 security pass should carry.
|
||||||
|
- Web work needs DB migration + UI surfaces (the "stream link
|
||||||
|
expired" path).
|
||||||
|
- Phase 2 hardenings ship value today without it; this is the
|
||||||
|
defense-in-depth layer on top.
|
||||||
661
Docs/plans/unarr-agent-roadmap.md
Normal file
661
Docs/plans/unarr-agent-roadmap.md
Normal file
|
|
@ -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]<vchain>[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=<seg>&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 → `<track>`), 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 → `<track>` desaparece (sin preview) y persiste `localStorage="0"`; toggle on → vuelven los 932 cues. CORS del `<img crossorigin>` 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]<vchain>[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 `<video>` 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 <pos>` antes de `-i`, `-frames:v 1`, MJPEG a stdout). Token scope `thumb:<sha256(path)>` (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/<id>` (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: `<expUnix>.<hexHMAC(secret, scope:exp)>`, TTL 6h.
|
||||||
|
- `/stream` + VLC: token en query `?t=`; scope `"stream"`.
|
||||||
|
- `/hls`: token en **path** `/hls/<sessionID>/<token>/<resource>`; scope `"hls:<sessionID>"`.
|
||||||
|
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 `<video>` → 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 <url>` + 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 <path>`).
|
||||||
|
|
||||||
|
**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/<id>/…` → 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 `<video src>` (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
|
||||||
|
`<video src>` 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: `<video>.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 `<video>` 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).
|
||||||
15
Makefile
15
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry
|
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push
|
||||||
|
|
||||||
BINARY = unarr
|
BINARY = unarr
|
||||||
SENTRY_DSN ?=
|
SENTRY_DSN ?=
|
||||||
|
|
@ -71,6 +71,19 @@ release-dry:
|
||||||
@test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; }
|
@test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; }
|
||||||
@./scripts/release.sh --dry-run $(V)
|
@./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
|
## Remove generated files
|
||||||
clean:
|
clean:
|
||||||
rm -f $(BINARY) coverage.out coverage.html
|
rm -f $(BINARY) coverage.out coverage.html
|
||||||
|
|
|
||||||
243
README.md
243
README.md
|
|
@ -11,9 +11,9 @@
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](go.mod)
|
[](go.mod)
|
||||||
|
|
||||||
Powerful terminal tool for torrent search and management. **Free and open source.**
|
The single-binary terminal client for torrent, debrid, and usenet downloads. **Free and open source.**
|
||||||
|
|
||||||
Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
|
Built-in torrent engine, debrid (Real-Debrid / AllDebrid), and NZB support. Stream to mpv/vlc, transcode on the fly with hardware acceleration, and manage your library — one binary or a headless daemon with WireGuard split-tunnel and Cloudflare Funnel remote access.
|
||||||
|
|
||||||
<!-- GIF demo placeholder -->
|
<!-- GIF demo placeholder -->
|
||||||
<!--  -->
|
<!--  -->
|
||||||
|
|
@ -171,6 +171,9 @@ unarr start
|
||||||
| `unarr status` | Show daemon status and active downloads |
|
| `unarr status` | Show daemon status and active downloads |
|
||||||
| `unarr daemon install` | Install as system service (systemd/launchd) |
|
| `unarr daemon install` | Install as system service (systemd/launchd) |
|
||||||
| `unarr daemon uninstall` | Remove the system service |
|
| `unarr daemon uninstall` | Remove the system service |
|
||||||
|
| `unarr vpn status` | Show managed-VPN config and live tunnel state |
|
||||||
|
| `unarr vpn enable` | Turn the managed VPN on |
|
||||||
|
| `unarr vpn disable` | Turn the managed VPN off |
|
||||||
|
|
||||||
### System & Diagnostics
|
### System & Diagnostics
|
||||||
|
|
||||||
|
|
@ -280,6 +283,53 @@ The daemon connects via WebSocket for instant task delivery, with automatic HTTP
|
||||||
- Linux: `~/.config/systemd/user/unarr.service` (systemd)
|
- Linux: `~/.config/systemd/user/unarr.service` (systemd)
|
||||||
- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd)
|
- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd)
|
||||||
|
|
||||||
|
## VPN
|
||||||
|
|
||||||
|
unarr can route your **downloads** through a managed WireGuard VPN, so peers and
|
||||||
|
trackers see the VPN server's IP instead of yours. It runs entirely in userspace
|
||||||
|
(wireguard-go + a gVisor netstack) — **no root, no `wg-quick`, no changes to your
|
||||||
|
OS routing table**.
|
||||||
|
|
||||||
|
Requires a **PRO+ plan with the VPN add-on**. Set it up at
|
||||||
|
[torrentclaw.com/vpn](https://torrentclaw.com/vpn).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Turn it on (writes [downloads.vpn] enabled = true to your config)
|
||||||
|
unarr vpn enable
|
||||||
|
|
||||||
|
# Restart the daemon so it brings the tunnel up at startup
|
||||||
|
unarr daemon restart # or: unarr start (if not installed as a service)
|
||||||
|
|
||||||
|
# Check it's working — shows the exit server when the tunnel is up
|
||||||
|
unarr vpn status
|
||||||
|
|
||||||
|
# Verify your account is provisioned (queries the API)
|
||||||
|
unarr vpn status --check
|
||||||
|
|
||||||
|
# Turn it off again
|
||||||
|
unarr vpn disable
|
||||||
|
```
|
||||||
|
|
||||||
|
**Split-tunnel — read this:** only the torrent client's traffic goes through the
|
||||||
|
VPN. Your browser, `curl`, and every other app keep using your **real IP** — that
|
||||||
|
is by design. To check the VPN is working, look at `unarr vpn status` (or the
|
||||||
|
peer/announce IP), **not** your browser's "what's my IP". To protect your other
|
||||||
|
devices (phone, laptop), use the **OpenVPN credentials** from your profile — those
|
||||||
|
support ~10 concurrent devices and do **not** share the agent's WireGuard slot.
|
||||||
|
|
||||||
|
**When does it fetch the config?** Once, at daemon startup. There's no periodic
|
||||||
|
refresh — after changing your exit server in the web panel or re-provisioning,
|
||||||
|
restart the daemon to pick it up. If the fetch fails the daemon logs a `[vpn]`
|
||||||
|
line and downloads in the clear (never refuses to run).
|
||||||
|
|
||||||
|
**Self-hosted / personal VPN:** instead of the managed config, point unarr at a
|
||||||
|
local WireGuard `.conf`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[downloads.vpn]
|
||||||
|
config_file = "/path/to/wg.conf" # takes precedence over `enabled`
|
||||||
|
```
|
||||||
|
|
||||||
## Diagnostics
|
## Diagnostics
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -293,6 +343,58 @@ 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.
|
`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<ver>/`), 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
|
## Clean
|
||||||
|
|
||||||
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting.
|
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting.
|
||||||
|
|
@ -374,6 +476,7 @@ tv_shows_dir = "~/Media/TV Shows"
|
||||||
[daemon]
|
[daemon]
|
||||||
poll_interval = "30s"
|
poll_interval = "30s"
|
||||||
heartbeat_interval = "30s"
|
heartbeat_interval = "30s"
|
||||||
|
auto_upgrade = true # apply server-flagged upgrades in-place (since 0.9.6)
|
||||||
|
|
||||||
[notifications]
|
[notifications]
|
||||||
enabled = true
|
enabled = true
|
||||||
|
|
@ -382,6 +485,142 @@ enabled = true
|
||||||
country = "US"
|
country = "US"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Streaming reference
|
||||||
|
|
||||||
|
The in-browser player on torrentclaw.com streams from the daemon over HLS
|
||||||
|
(HTTP fragments + ffmpeg transcode for codecs the browser can't decode
|
||||||
|
natively). Enabled by default — a fresh install "just works" without editing
|
||||||
|
the TOML.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[downloads.transcode]
|
||||||
|
enabled = true # master switch
|
||||||
|
hw_accel = "auto" # auto | none | nvenc | qsv | vaapi | videotoolbox
|
||||||
|
preset = "veryfast" # libx264 preset
|
||||||
|
video_bitrate = "" # e.g. "5M" caps -b:v; empty = engine fallback (5M)
|
||||||
|
audio_bitrate = "192k" # e.g. "128k", "192k", "256k"
|
||||||
|
max_height = 0 # 0 = no cap; e.g. 720 forces 720p max
|
||||||
|
max_concurrent = 2 # max simultaneous ffmpeg processes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `[downloads.transcode]`
|
||||||
|
|
||||||
|
| Key | Type | Default | Notes |
|
||||||
|
|-----|------|---------|-------|
|
||||||
|
| `enabled` | bool | `true` | Real-time HLS transcoding when source codec is browser-incompatible (HEVC, AV1, AC3, DTS). Requires `ffmpeg` + `ffprobe` on PATH. |
|
||||||
|
| `hw_accel` | string | `"auto"` | Hardware accel: `"auto"`, `"none"`, `"nvenc"` (NVIDIA), `"qsv"` (Intel), `"vaapi"` (Linux), `"videotoolbox"` (macOS). |
|
||||||
|
| `preset` | string | `"veryfast"` | libx264 preset. Slower preset = smaller files but higher CPU. Options: `ultrafast`, `superfast`, `veryfast`, `faster`, `fast`, `medium`, `slow`, `slower`, `veryslow`. |
|
||||||
|
| `video_bitrate` | string | `""` | E.g. `"5M"` caps `-b:v`. Empty falls back to the engine default (`5M`). |
|
||||||
|
| `audio_bitrate` | string | `"192k"` | E.g. `"128k"`, `"256k"`. |
|
||||||
|
| `max_height` | int | `0` | `0` = no cap. E.g. `720` forces 720p max — useful on weak GPUs. |
|
||||||
|
| `max_concurrent` | int | `2` | Max simultaneous ffmpeg processes. Increase if hosting multiple users on a beefy box. |
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|-----|------|---------|-------|
|
||||||
|
| `enabled` | bool | `false` | Managed VPN: at startup the daemon fetches a WireGuard config from your account and split-tunnels torrent traffic through it. Needs a PRO+ plan with the VPN add-on. Toggle with `unarr vpn enable` / `disable`. |
|
||||||
|
| `config_file` | string | `""` | Self-hosted / personal VPN: path to a local WireGuard `.conf`. **Takes precedence over `enabled`** — when set, the daemon uses this file and never calls the API. |
|
||||||
|
|
||||||
|
See the [VPN](#vpn) section above for how it works (split-tunnel, no root) and
|
||||||
|
how to protect your other devices.
|
||||||
|
|
||||||
|
#### `[downloads.funnel]` — public HTTPS hostname for the daemon (CloudFlare Quick Tunnel)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[downloads.funnel]
|
||||||
|
enabled = false # off by default
|
||||||
|
```
|
||||||
|
|
||||||
|
| Key | Type | Default | Notes |
|
||||||
|
|-----|------|---------|-------|
|
||||||
|
| `enabled` | bool | `false` | Spawns `cloudflared tunnel --url http://localhost:<stream_port>` as a child process at daemon startup. Toggle with `unarr funnel on` / `off`. Requires `cloudflared` on PATH. |
|
||||||
|
|
||||||
|
**What it does.** Without a tunnel, the daemon is reachable on `localhost`,
|
||||||
|
your LAN, and (if installed) Tailscale. That covers the same-machine and
|
||||||
|
Tailscale-connected cases, but the **browser-based player on torrentclaw.com
|
||||||
|
fails on any other network** because HTTPS pages can't fetch HTTP resources
|
||||||
|
("mixed content"). Enabling the funnel gives the daemon a public
|
||||||
|
`https://<random>.trycloudflare.com` hostname so the web player picks it up
|
||||||
|
and playback works from anywhere — phone on cellular, friend's laptop on a
|
||||||
|
foreign Wi-Fi, anywhere. The Stremio addon already works cross-network
|
||||||
|
(native mpv/VLC players ignore CORS), so this is strictly a web-player fix.
|
||||||
|
|
||||||
|
**Privacy posture.** Bytes pass through CloudFlare's edge — TorrentClaw never
|
||||||
|
relays content (we don't see your traffic), CloudFlare does. Quick Tunnels
|
||||||
|
are **anonymous** (no CF account required); the registration is unauthenticated
|
||||||
|
and the hostname is a random label, but CF logs request metadata like any CDN
|
||||||
|
would. If you want zero third-party byte access, use Tailscale instead.
|
||||||
|
|
||||||
|
**Limitations (free Quick Tunnels).**
|
||||||
|
| Aspect | Limit |
|
||||||
|
|--------|-------|
|
||||||
|
| Session lifetime | ~6 hours, then the hostname rotates. cloudflared re-registers automatically; the web picks up the new URL on the next sync. In-flight HLS sessions break across the rotation (browser retries). |
|
||||||
|
| Bandwidth | No documented hard cap, but CF reserves the right to throttle. 1080p HLS (~6 Mbps) is fine; 4K HEVC at 25 Mbps may hit throttling. |
|
||||||
|
| Latency | +20–80 ms vs direct LAN/Tailscale (extra hop browser → CF edge → tunnel). HLS player buffer absorbs it. |
|
||||||
|
| Concurrency | One tunnel serves N viewers. CF rate-limits ~200 req/s, plenty for HLS segments. |
|
||||||
|
| TOS | CloudFlare flags Quick Tunnels as "not for production traffic". They can decommission an abusive tunnel without notice. |
|
||||||
|
|
||||||
|
For heavy / high-throughput / persistent-URL use cases, switch to a CloudFlare
|
||||||
|
Named Tunnel (free, needs a CF account) or run your own reverse proxy — both
|
||||||
|
out of scope for the bundled command.
|
||||||
|
|
||||||
|
**Disable.** `unarr funnel off` flips `enabled` to `false` in the TOML and
|
||||||
|
prompts you to restart the daemon. You can also edit `config.toml` directly:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[downloads.funnel]
|
||||||
|
enabled = false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Install cloudflared.**
|
||||||
|
- Linux: `apt install cloudflared` (after adding CF's apt repo) — see
|
||||||
|
<https://pkg.cloudflare.com>. Or pull the static binary from
|
||||||
|
<https://github.com/cloudflare/cloudflared/releases>.
|
||||||
|
- macOS: `brew install cloudflared`.
|
||||||
|
- Windows: `winget install --id Cloudflare.cloudflared`.
|
||||||
|
|
||||||
|
If `cloudflared` is not on PATH the daemon logs a warning at startup and
|
||||||
|
falls back to LAN/Tailscale-only reachability.
|
||||||
|
|
||||||
### Environment variables
|
### Environment variables
|
||||||
|
|
||||||
Environment variables override config file values:
|
Environment variables override config file values:
|
||||||
|
|
|
||||||
44
SECURITY.md
44
SECURITY.md
|
|
@ -59,6 +59,50 @@ This project follows these security practices:
|
||||||
- **Non-root Docker** — Container runs as unprivileged user (UID 1000)
|
- **Non-root Docker** — Container runs as unprivileged user (UID 1000)
|
||||||
- **Dependency scanning** — Automated via Dependabot
|
- **Dependency scanning** — Automated via Dependabot
|
||||||
|
|
||||||
|
## Container Image Vulnerability Scanning
|
||||||
|
|
||||||
|
The Docker image (`torrentclaw/unarr`) is scanned by Docker Scout on Docker Hub and
|
||||||
|
by a CVE gate in CI (see `.github/workflows/`). Two things matter when reading the
|
||||||
|
Docker Hub vulnerability count:
|
||||||
|
|
||||||
|
- **Scanner database differs.** Docker Hub (Scout) matches `package@version` against
|
||||||
|
NVD/GHSA. Trivy/Alpine `secdb` only lists CVEs Alpine has acknowledged and patched.
|
||||||
|
A high Scout count with a clean Trivy report is expected, not a contradiction.
|
||||||
|
- **The bulk comes from the bundled `ffmpeg` codec stack.** Alpine's `ffmpeg`
|
||||||
|
package pulls ~40 codec/parser libraries (`x264`, `x265`, `libvpx`, `aom`,
|
||||||
|
`dav1d`, `libtheora`, `libvorbis`, `libwebp`, `libbluray`, `libopenmpt`, …).
|
||||||
|
Each carries a long NVD history that Alpine does not backport. ffmpeg is a
|
||||||
|
**functional dependency** — the HLS transcode pipeline shells out to
|
||||||
|
`ffmpeg`/`ffprobe` to decode untrusted media and re-encode to H.264 + AAC.
|
||||||
|
|
||||||
|
### Accepted risk and policy
|
||||||
|
|
||||||
|
- **Fixable** CRITICAL/HIGH findings **block** a release (CI CVE gate, `only-fixed`).
|
||||||
|
- **Unfixed-upstream** codec CVEs are tracked but **accepted**: there is no patched
|
||||||
|
Alpine package to move to, and dropping codecs would break playback of common
|
||||||
|
formats. They are mitigated by the hardening below rather than eliminated.
|
||||||
|
- Images are **rebuilt and re-pushed weekly** (scheduled workflow) so any newly
|
||||||
|
*fixed* base/ffmpeg/Go patch lands between tagged releases.
|
||||||
|
|
||||||
|
### Mitigations (run the container hardened)
|
||||||
|
|
||||||
|
Crafted media (torrents are untrusted input) is the realistic attack vector against
|
||||||
|
ffmpeg's parsers. The shipped `docker-compose.yml` already applies:
|
||||||
|
|
||||||
|
- **Non-root** user (UID 1000), **read-only** root filesystem, writable `tmpfs` only.
|
||||||
|
- **Resource limits** (memory/CPU) to bound a runaway decode.
|
||||||
|
|
||||||
|
Recommended additions for exposed deployments:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cap_drop: ["ALL"]
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
```
|
||||||
|
|
||||||
|
If you do not need HLS transcoding, you can run with transcoding disabled to
|
||||||
|
avoid feeding untrusted media to ffmpeg at all.
|
||||||
|
|
||||||
## Disclosure Policy
|
## Disclosure Policy
|
||||||
|
|
||||||
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.
|
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,77 @@
|
||||||
|
# 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:
|
services:
|
||||||
unarr:
|
unarr:
|
||||||
build:
|
|
||||||
context: ..
|
|
||||||
dockerfile: unarr/Dockerfile
|
|
||||||
image: torrentclaw/unarr:latest
|
image: torrentclaw/unarr:latest
|
||||||
|
pull_policy: always # always pull on `up` so you stay on the latest release
|
||||||
container_name: unarr
|
container_name: unarr
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
user: "1000:1000"
|
|
||||||
|
|
||||||
# Read-only root filesystem — only volumes are writable
|
# host network is required for:
|
||||||
read_only: true
|
# - streaming to reach your TV / mobile / other LAN devices (port 11818)
|
||||||
tmpfs:
|
# - HLS transcode server (port 11819)
|
||||||
- /tmp:size=64m,mode=1777
|
# - Tailscale connectivity (if you use it)
|
||||||
|
# On macOS / Windows Docker Desktop, replace with `ports` mapping (see below).
|
||||||
volumes:
|
|
||||||
# Config: your config.toml lives here
|
|
||||||
- ./config:/config
|
|
||||||
# Downloads: finished media goes here
|
|
||||||
- ~/Media:/downloads
|
|
||||||
# Data: torrent metadata, piece DB, cache
|
|
||||||
- unarr-data:/data
|
|
||||||
|
|
||||||
environment:
|
|
||||||
- TZ=${TZ:-UTC}
|
|
||||||
# Optional overrides (uncomment to use):
|
|
||||||
# - UNARR_API_KEY=tc_your_key_here
|
|
||||||
# - UNARR_API_URL=https://torrentclaw.com
|
|
||||||
|
|
||||||
# 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
|
network_mode: host
|
||||||
|
|
||||||
# Option B: bridge network with port mapping (more isolated)
|
environment:
|
||||||
# Uncomment below and comment out network_mode above:
|
# --- 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}
|
||||||
|
|
||||||
|
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).
|
||||||
|
- 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"
|
||||||
|
|
||||||
|
# --- macOS / Windows alternative (replace network_mode: host above) ---
|
||||||
|
# network_mode: bridge
|
||||||
# ports:
|
# ports:
|
||||||
# - "6881-6889:6881-6889/tcp"
|
# - "11818:11818" # direct stream (VLC, download)
|
||||||
# - "6881-6889:6881-6889/udp"
|
# - "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.
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
unarr-data:
|
unarr-data:
|
||||||
|
|
|
||||||
13
go.mod
13
go.mod
|
|
@ -15,8 +15,9 @@ require (
|
||||||
github.com/olekukonko/tablewriter v1.1.4
|
github.com/olekukonko/tablewriter v1.1.4
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/torrentclaw/go-client v0.2.0
|
github.com/torrentclaw/go-client v0.2.0
|
||||||
golang.org/x/term v0.41.0
|
golang.org/x/term v0.43.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|
@ -121,12 +122,14 @@ require (
|
||||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||||
golang.org/x/crypto v0.49.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
|
||||||
golang.org/x/net v0.52.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.42.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.35.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
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
modernc.org/libc v1.70.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
|
|
||||||
34
go.sum
34
go.sum
|
|
@ -473,8 +473,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
|
||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
|
||||||
|
|
@ -485,8 +485,8 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
|
@ -500,8 +500,8 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|
@ -532,18 +532,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||||
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|
@ -554,12 +554,16 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
|
||||||
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
|
||||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
|
||||||
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
|
||||||
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
|
||||||
|
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||||
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
|
||||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
|
@ -587,6 +591,8 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
|
||||||
|
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
|
||||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
|
||||||
|
|
|
||||||
105
internal/agent/active_tasks.go
Normal file
105
internal/agent/active_tasks.go
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
75
internal/agent/active_tasks_test.go
Normal file
75
internal/agent/active_tasks_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,8 +12,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client communicates with the /api/internal/agent/* endpoints.
|
// Client communicates with the /api/internal/agent/* endpoints.
|
||||||
|
//
|
||||||
|
// The client owns a MirrorPool: when a request fails with a transient
|
||||||
|
// network error (DNS, refused, timeout, 5xx) it rotates to the next mirror
|
||||||
|
// and retries up to `len(mirrors)-1` times so a single agent run survives
|
||||||
|
// a primary-domain takedown without user intervention.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
baseURL string
|
pool *MirrorPool
|
||||||
apiKey string
|
apiKey string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
// wakeClient has no built-in timeout — used exclusively for the long-poll
|
// wakeClient has no built-in timeout — used exclusively for the long-poll
|
||||||
|
|
@ -25,10 +30,19 @@ type Client struct {
|
||||||
userAgent string
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates an agent API client.
|
// NewClient creates an agent API client targeting a single base URL.
|
||||||
|
// Equivalent to NewClientWithMirrors(baseURL, nil, ...) — kept for callers
|
||||||
|
// that don't yet care about mirror failover.
|
||||||
func NewClient(baseURL, apiKey, userAgent string) *Client {
|
func NewClient(baseURL, apiKey, userAgent string) *Client {
|
||||||
|
return NewClientWithMirrors(baseURL, nil, apiKey, userAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWithMirrors creates an agent API client that can fail over from
|
||||||
|
// the primary base URL to any of the extras when the primary is unreachable.
|
||||||
|
// The order of `extras` matters: they're tried left-to-right after a failure.
|
||||||
|
func NewClientWithMirrors(baseURL string, extras []string, apiKey, userAgent string) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
pool: NewMirrorPool(baseURL, extras),
|
||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
|
@ -44,6 +58,18 @@ func NewClient(baseURL, apiKey, userAgent string) *Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MirrorPool exposes the underlying pool so callers (e.g. the `unarr mirrors`
|
||||||
|
// subcommand) can swap the list at runtime after fetching /api/v1/mirrors.
|
||||||
|
func (c *Client) MirrorPool() *MirrorPool {
|
||||||
|
return c.pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// baseURL returns the currently-active mirror. Routed through this helper so
|
||||||
|
// future changes (e.g. per-endpoint mirror affinity) only need one edit.
|
||||||
|
func (c *Client) baseURL() string {
|
||||||
|
return c.pool.Current()
|
||||||
|
}
|
||||||
|
|
||||||
// Register registers the CLI agent with the server and returns user info + features.
|
// Register registers the CLI agent with the server and returns user info + features.
|
||||||
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
|
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
|
||||||
var resp RegisterResponse
|
var resp RegisterResponse
|
||||||
|
|
@ -65,6 +91,66 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
|
||||||
return nil
|
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/<id>/master.m3u8.
|
||||||
|
//
|
||||||
|
// Best-effort: the server is the source of truth for session state and
|
||||||
|
// will reach the same conclusion via HEAD probes anyway if this call
|
||||||
|
// fails. We log the error in the caller but don't retry — by the time
|
||||||
|
// a retry would land the user is likely already playing.
|
||||||
|
func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error {
|
||||||
|
req := struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
}{SessionID: sessionID}
|
||||||
|
var resp StatusResponse
|
||||||
|
if err := c.doPost(ctx, "/api/internal/agent/session-ready", req, &resp); err != nil {
|
||||||
|
return fmt.Errorf("mark session ready: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
|
||||||
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
|
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
|
||||||
var resp StatusResponse
|
var resp StatusResponse
|
||||||
|
|
@ -109,30 +195,35 @@ func (c *Client) SearchNzbs(ctx context.Context, params NzbSearchParams) (*NzbSe
|
||||||
// DownloadNzb downloads the NZB file for the given nzbId.
|
// DownloadNzb downloads the NZB file for the given nzbId.
|
||||||
// Returns the raw NZB XML bytes.
|
// Returns the raw NZB XML bytes.
|
||||||
func (c *Client) DownloadNzb(ctx context.Context, nzbID string) ([]byte, error) {
|
func (c *Client) DownloadNzb(ctx context.Context, nzbID string) ([]byte, error) {
|
||||||
url := fmt.Sprintf("/api/internal/agent/nzb-download?nzbId=%s", nzbID)
|
path := fmt.Sprintf("/api/internal/agent/nzb-download?nzbId=%s", nzbID)
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+url, nil)
|
var out []byte
|
||||||
|
err := c.withMirrorFailover(func(base string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create request: %w", err)
|
return fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
c.setHeaders(req)
|
c.setHeaders(req)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
return fmt.Errorf("request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
|
||||||
return nil, fmt.Errorf("nzb download error %d: %s", resp.StatusCode, string(body))
|
return &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit
|
data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read nzb: %w", err)
|
return fmt.Errorf("read nzb: %w", err)
|
||||||
}
|
}
|
||||||
return data, nil
|
out = data
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUsenetCredentials fetches NNTP connection credentials.
|
// GetUsenetCredentials fetches NNTP connection credentials.
|
||||||
|
|
@ -193,31 +284,41 @@ func (c *Client) ReportWatchProgress(ctx context.Context, update WatchProgressUp
|
||||||
// WaitForWake blocks until the server sends a wake signal, the long-poll
|
// WaitForWake blocks until the server sends a wake signal, the long-poll
|
||||||
// timeout elapses, or ctx is cancelled. Returns true when a wake signal
|
// timeout elapses, or ctx is cancelled. Returns true when a wake signal
|
||||||
// was received (caller should sync immediately), false on timeout/cancel.
|
// was received (caller should sync immediately), false on timeout/cancel.
|
||||||
|
//
|
||||||
|
// Wake is a long-poll on a single mirror — failover here would just drop
|
||||||
|
// the connection and try again immediately, which the server already
|
||||||
|
// handles with a fresh wait loop. We only retry against the next mirror
|
||||||
|
// when the current one is definitively unreachable (DNS / refused / TLS).
|
||||||
func (c *Client) WaitForWake(ctx context.Context) (bool, error) {
|
func (c *Client) WaitForWake(ctx context.Context) (bool, error) {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/internal/agent/wake", nil)
|
var wake bool
|
||||||
|
err := c.withMirrorFailover(func(base string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/internal/agent/wake", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("create wake request: %w", err)
|
return fmt.Errorf("create wake request: %w", err)
|
||||||
}
|
}
|
||||||
c.setHeaders(req)
|
c.setHeaders(req)
|
||||||
|
|
||||||
resp, err := c.wakeClient.Do(req)
|
resp, err := c.wakeClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("wake request failed: %w", err)
|
return fmt.Errorf("wake request failed: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
|
||||||
return false, &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
|
return &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
|
||||||
}
|
}
|
||||||
|
|
||||||
var result struct {
|
var result struct {
|
||||||
Wake bool `json:"wake"`
|
Wake bool `json:"wake"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
return false, fmt.Errorf("decode wake response: %w", err)
|
return fmt.Errorf("decode wake response: %w", err)
|
||||||
}
|
}
|
||||||
return result.Wake, nil
|
wake = result.Wake
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return wake, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// doPost sends a JSON POST request using the default httpClient and decodes the response.
|
// doPost sends a JSON POST request using the default httpClient and decodes the response.
|
||||||
|
|
@ -227,13 +328,16 @@ func (c *Client) doPost(ctx context.Context, path string, body any, dst any) err
|
||||||
|
|
||||||
// doPostWith sends a JSON POST request using the provided HTTP client and decodes the response.
|
// doPostWith sends a JSON POST request using the provided HTTP client and decodes the response.
|
||||||
// Use this to override the default timeout for specific operations (e.g. librarySyncClient).
|
// Use this to override the default timeout for specific operations (e.g. librarySyncClient).
|
||||||
|
// Wrapped in withMirrorFailover so a transient connection failure on the
|
||||||
|
// active mirror retries against the next one.
|
||||||
func (c *Client) doPostWith(ctx context.Context, hc *http.Client, path string, body any, dst any) error {
|
func (c *Client) doPostWith(ctx context.Context, hc *http.Client, path string, body any, dst any) error {
|
||||||
jsonBody, err := json.Marshal(body)
|
jsonBody, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal body: %w", err)
|
return fmt.Errorf("marshal body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody))
|
return c.withMirrorFailover(func(base string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(jsonBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create request: %w", err)
|
return fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -248,11 +352,13 @@ func (c *Client) doPostWith(ctx context.Context, hc *http.Client, path string, b
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
return c.handleResponse(resp, dst)
|
return c.handleResponse(resp, dst)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// doGet sends a GET request and decodes the response.
|
// doGet sends a GET request and decodes the response.
|
||||||
func (c *Client) doGet(ctx context.Context, path string, dst any) error {
|
func (c *Client) doGet(ctx context.Context, path string, dst any) error {
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
|
return c.withMirrorFailover(func(base string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create request: %w", err)
|
return fmt.Errorf("create request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -266,6 +372,45 @@ func (c *Client) doGet(ctx context.Context, path string, dst any) error {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
return c.handleResponse(resp, dst)
|
return c.handleResponse(resp, dst)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// withMirrorFailover runs `fn` against the current mirror; on a transient
|
||||||
|
// error it rotates the pool and retries up to `len(mirrors)-1` times.
|
||||||
|
//
|
||||||
|
// The active mirror is updated on rotation so subsequent unrelated calls
|
||||||
|
// stick to the working host until that host fails too — this avoids
|
||||||
|
// hammering a known-bad primary on every request, while still trying it
|
||||||
|
// again next time the agent reloads (no permanent demotion).
|
||||||
|
func (c *Client) withMirrorFailover(fn func(base string) error) error {
|
||||||
|
attempts := c.pool.Len()
|
||||||
|
if attempts < 1 {
|
||||||
|
attempts = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
base := c.baseURL()
|
||||||
|
err := fn(base)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
if !IsTransient(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Last attempt: don't bother rotating, just surface the error.
|
||||||
|
if i == attempts-1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next, rotated := c.pool.Rotate()
|
||||||
|
if !rotated {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
_ = next // mirror rotation logging is left to higher layers (cmd/) so the
|
||||||
|
// pool stays log-free for tests.
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) setHeaders(req *http.Request) {
|
func (c *Client) setHeaders(req *http.Request) {
|
||||||
|
|
|
||||||
|
|
@ -498,7 +498,7 @@ func TestClient_SlowServer_Timeout(t *testing.T) {
|
||||||
|
|
||||||
// Crear cliente con timeout muy corto
|
// Crear cliente con timeout muy corto
|
||||||
c := &Client{
|
c := &Client{
|
||||||
baseURL: srv.URL,
|
pool: NewMirrorPool(srv.URL, nil),
|
||||||
apiKey: "test-key",
|
apiKey: "test-key",
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 50 * time.Millisecond,
|
Timeout: 50 * time.Millisecond,
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,13 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/torrentclaw/unarr/internal/upgrade"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DaemonConfig holds daemon runtime settings.
|
// DaemonConfig holds daemon runtime settings.
|
||||||
|
|
@ -19,8 +22,23 @@ type DaemonConfig struct {
|
||||||
Version string
|
Version string
|
||||||
DownloadDir string
|
DownloadDir string
|
||||||
StreamPort int // port for the HTTP stream server
|
StreamPort int // port for the HTTP stream server
|
||||||
|
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)
|
LanIP string // LAN IP (reported in sync for stream URL resolution)
|
||||||
TailscaleIP string // Tailscale 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.
|
// Daemon manages agent registration and the sync loop.
|
||||||
|
|
@ -33,6 +51,7 @@ type Daemon struct {
|
||||||
// Callbacks — set by cmd/daemon.go before calling Run.
|
// Callbacks — set by cmd/daemon.go before calling Run.
|
||||||
OnTasksClaimed func(tasks []Task)
|
OnTasksClaimed func(tasks []Task)
|
||||||
OnStreamRequested func(req StreamRequest)
|
OnStreamRequested func(req StreamRequest)
|
||||||
|
OnStreamSession func(sess StreamSession)
|
||||||
OnControlAction func(action, taskID string, deleteFiles bool)
|
OnControlAction func(action, taskID string, deleteFiles bool)
|
||||||
GetActiveCount func() int // returns number of active downloads (wired from manager)
|
GetActiveCount func() int // returns number of active downloads (wired from manager)
|
||||||
|
|
||||||
|
|
@ -43,6 +62,16 @@ type Daemon struct {
|
||||||
State DaemonState
|
State DaemonState
|
||||||
lastNotifiedVersion string
|
lastNotifiedVersion string
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
vpnActive bool
|
||||||
|
vpnMode string
|
||||||
|
vpnServer string
|
||||||
|
|
||||||
|
// CloudFlare Quick Tunnel public URL; folded into DaemonState + heartbeat
|
||||||
|
// so the web can prefer it over Tailscale/LAN for in-browser playback.
|
||||||
|
funnelURL string
|
||||||
|
|
||||||
// Watching tracks whether a user is viewing download progress in the web UI.
|
// Watching tracks whether a user is viewing download progress in the web UI.
|
||||||
Watching atomic.Bool
|
Watching atomic.Bool
|
||||||
|
|
||||||
|
|
@ -65,6 +94,30 @@ func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
|
||||||
// SyncClient returns the sync client for external wiring.
|
// SyncClient returns the sync client for external wiring.
|
||||||
func (d *Daemon) SyncClient() *SyncClient { return d.sync }
|
func (d *Daemon) SyncClient() *SyncClient { return d.sync }
|
||||||
|
|
||||||
|
// SetVPNState records the managed-VPN split-tunnel state so it's reflected in the
|
||||||
|
// daemon state file (read by `unarr vpn status`). Call before Run.
|
||||||
|
func (d *Daemon) SetVPNState(active bool, mode, server string) {
|
||||||
|
d.vpnActive = active
|
||||||
|
d.vpnMode = mode
|
||||||
|
d.vpnServer = server
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFunnelURL records the CloudFlare Quick Tunnel hostname so it's reflected
|
||||||
|
// in the daemon state file (read by `unarr funnel status`) and in heartbeat
|
||||||
|
// requests (so the web prefers it over Tailscale/LAN). Pass "" to clear.
|
||||||
|
func (d *Daemon) SetFunnelURL(url string) {
|
||||||
|
d.funnelURL = url
|
||||||
|
d.State.FunnelURL = url
|
||||||
|
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.
|
// UpdateStreamPort updates the stream port reported in sync requests.
|
||||||
func (d *Daemon) UpdateStreamPort(port int) {
|
func (d *Daemon) UpdateStreamPort(port int) {
|
||||||
d.cfg.StreamPort = port
|
d.cfg.StreamPort = port
|
||||||
|
|
@ -82,8 +135,20 @@ func (d *Daemon) Register(ctx context.Context) error {
|
||||||
Version: d.cfg.Version,
|
Version: d.cfg.Version,
|
||||||
DownloadDir: d.cfg.DownloadDir,
|
DownloadDir: d.cfg.DownloadDir,
|
||||||
StreamPort: d.cfg.StreamPort,
|
StreamPort: d.cfg.StreamPort,
|
||||||
|
StreamSecret: d.cfg.StreamSecret,
|
||||||
LanIP: d.cfg.LanIP,
|
LanIP: d.cfg.LanIP,
|
||||||
TailscaleIP: d.cfg.TailscaleIP,
|
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 {
|
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
|
||||||
req.DiskFreeBytes = free
|
req.DiskFreeBytes = free
|
||||||
|
|
@ -134,6 +199,10 @@ func (d *Daemon) Register(ctx context.Context) error {
|
||||||
PID: os.Getpid(),
|
PID: os.Getpid(),
|
||||||
StartedAt: now,
|
StartedAt: now,
|
||||||
MethodStats: make(map[string]int),
|
MethodStats: make(map[string]int),
|
||||||
|
VPNActive: d.vpnActive,
|
||||||
|
VPNMode: d.vpnMode,
|
||||||
|
VPNServer: d.vpnServer,
|
||||||
|
FunnelURL: d.funnelURL,
|
||||||
}
|
}
|
||||||
WriteState(&d.State)
|
WriteState(&d.State)
|
||||||
|
|
||||||
|
|
@ -151,6 +220,21 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||||
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
|
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
|
||||||
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
|
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
|
||||||
|
|
||||||
|
// Usenet needs par2 (segment repair) + an extractor (RAR/7z) on the host.
|
||||||
|
// Without par2, a single bad segment corrupts the file silently; without
|
||||||
|
// an extractor, RAR-packed downloads can't be unpacked. Warn loudly at
|
||||||
|
// startup so the operator installs them before the first download fails.
|
||||||
|
if d.Features.Usenet {
|
||||||
|
if _, err := exec.LookPath("par2"); err != nil {
|
||||||
|
log.Printf("[usenet] WARNING: par2 not found in PATH — corrupted segments cannot be repaired and extraction may fail. Install par2 (apt install par2 / brew install par2).")
|
||||||
|
}
|
||||||
|
_, unrarErr := exec.LookPath("unrar")
|
||||||
|
_, sevenZErr := exec.LookPath("7z")
|
||||||
|
if unrarErr != nil && sevenZErr != nil {
|
||||||
|
log.Printf("[usenet] WARNING: no archive extractor (unrar or 7z) found — RAR-packed downloads cannot be unpacked. Install unrar or 7z.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wire sync callbacks
|
// Wire sync callbacks
|
||||||
d.sync.OnNewTasks = func(tasks []Task) {
|
d.sync.OnNewTasks = func(tasks []Task) {
|
||||||
if d.OnTasksClaimed != nil {
|
if d.OnTasksClaimed != nil {
|
||||||
|
|
@ -163,15 +247,30 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.sync.OnStreamRequest = func(req StreamRequest) {
|
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 {
|
if d.OnStreamRequested != nil {
|
||||||
d.OnStreamRequested(req)
|
go d.OnStreamRequested(req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d.sync.OnStreamSession = func(sess StreamSession) {
|
||||||
|
if d.OnStreamSession != nil {
|
||||||
|
d.OnStreamSession(sess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
d.sync.OnUpgrade = func(version string) {
|
d.sync.OnUpgrade = func(version string) {
|
||||||
if version != d.lastNotifiedVersion {
|
if version == d.lastNotifiedVersion {
|
||||||
d.lastNotifiedVersion = version
|
return
|
||||||
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", version)
|
|
||||||
}
|
}
|
||||||
|
d.lastNotifiedVersion = version
|
||||||
|
if !d.cfg.AutoUpgrade {
|
||||||
|
log.Printf("[upgrade] new version available: %s — auto_upgrade=false, run `unarr update` to apply", version)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[upgrade] new version available: %s — applying auto-upgrade", version)
|
||||||
|
go d.applyAutoUpgrade(version)
|
||||||
}
|
}
|
||||||
d.sync.OnScan = func() {
|
d.sync.OnScan = func() {
|
||||||
log.Printf("Library scan requested by server")
|
log.Printf("Library scan requested by server")
|
||||||
|
|
@ -183,6 +282,12 @@ func (d *Daemon) Run(ctx context.Context) error {
|
||||||
d.sync.OnWatchingChange = func(watching bool) {
|
d.sync.OnWatchingChange = func(watching bool) {
|
||||||
d.Watching.Store(watching)
|
d.Watching.Store(watching)
|
||||||
}
|
}
|
||||||
|
d.sync.GetVPNState = func() (bool, string, string) {
|
||||||
|
return d.vpnActive, d.vpnMode, d.vpnServer
|
||||||
|
}
|
||||||
|
d.sync.GetFunnelURL = func() string {
|
||||||
|
return d.funnelURL
|
||||||
|
}
|
||||||
d.sync.OnSyncSuccess = func() {
|
d.sync.OnSyncSuccess = func() {
|
||||||
d.State.LastHeartbeat = time.Now()
|
d.State.LastHeartbeat = time.Now()
|
||||||
if d.GetActiveCount != nil {
|
if d.GetActiveCount != nil {
|
||||||
|
|
@ -212,6 +317,67 @@ func (d *Daemon) Deregister() {
|
||||||
RemoveState()
|
RemoveState()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyAutoUpgrade downloads the target version and exits so the service
|
||||||
|
// supervisor (systemd Restart=always on Linux) respawns on the new binary.
|
||||||
|
// Triggered by the server's upgrade signal — opt-in flag set by the user from
|
||||||
|
// the web UI; the daemon never auto-upgrades on a passive version bump.
|
||||||
|
//
|
||||||
|
// Reports the outcome to /api/internal/agent/upgrade-result so the server
|
||||||
|
// clears `upgrade_requested`. Without this report the flag stays sticky and
|
||||||
|
// the daemon would loop on every sync — including the no-op case where it's
|
||||||
|
// already on the target version.
|
||||||
|
func (d *Daemon) applyAutoUpgrade(targetVersion string) {
|
||||||
|
currentClean := strings.TrimPrefix(d.cfg.Version, "v")
|
||||||
|
targetClean := strings.TrimPrefix(targetVersion, "v")
|
||||||
|
|
||||||
|
// No-op: server signal arrived but we're already running the target. This
|
||||||
|
// happens when the daemon restarts after a previous auto-upgrade before
|
||||||
|
// reportUpgradeResult cleared the flag, or when the operator manually
|
||||||
|
// installed the same version off-band. Skip Execute (which would also
|
||||||
|
// no-op) AND skip os.Exit, but DO clear the flag — otherwise we loop.
|
||||||
|
if currentClean == targetClean {
|
||||||
|
log.Printf("[upgrade] already on v%s — clearing server flag", currentClean)
|
||||||
|
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancelR()
|
||||||
|
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, currentClean, ""); err != nil {
|
||||||
|
log.Printf("[upgrade] report-result failed (will retry on next signal): %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
upgrader := &upgrade.Upgrader{
|
||||||
|
CurrentVersion: currentClean,
|
||||||
|
OnProgress: func(msg string) {
|
||||||
|
log.Printf("[upgrade] %s", msg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
result := upgrader.Execute(ctx, targetVersion)
|
||||||
|
if !result.Success {
|
||||||
|
log.Printf("[upgrade] auto-upgrade failed: %v", result.Error)
|
||||||
|
errMsg := ""
|
||||||
|
if result.Error != nil {
|
||||||
|
errMsg = result.Error.Error()
|
||||||
|
}
|
||||||
|
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancelR()
|
||||||
|
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, false, targetClean, errMsg); err != nil {
|
||||||
|
log.Printf("[upgrade] report-result failed: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[upgrade] upgraded v%s → v%s; reporting result + exiting so service supervisor restarts on new binary",
|
||||||
|
result.OldVersion, result.NewVersion)
|
||||||
|
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, result.NewVersion, ""); err != nil {
|
||||||
|
log.Printf("[upgrade] report-result failed: %v", err)
|
||||||
|
}
|
||||||
|
cancelR()
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
// isTransientError returns true for errors worth retrying (429, 5xx, network).
|
// isTransientError returns true for errors worth retrying (429, 5xx, network).
|
||||||
func isTransientError(err error) bool {
|
func isTransientError(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
|
||||||
62
internal/agent/disk_test.go
Normal file
62
internal/agent/disk_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDirSize(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "a.bin"), make([]byte, 100), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "sub", "b.bin"), make([]byte, 250), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := DirSize(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DirSize error: %v", err)
|
||||||
|
}
|
||||||
|
if got != 350 {
|
||||||
|
t.Errorf("DirSize = %d, want 350", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSizeEmpty(t *testing.T) {
|
||||||
|
got, err := DirSize(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DirSize empty dir error: %v", err)
|
||||||
|
}
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("DirSize empty = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDirSizeMissing(t *testing.T) {
|
||||||
|
// Walk skips unreadable entries — missing path returns 0 with no error.
|
||||||
|
got, err := DirSize("/nonexistent/path/zzz")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("DirSize on missing path = err %v, want nil", err)
|
||||||
|
}
|
||||||
|
if got != 0 {
|
||||||
|
t.Errorf("DirSize on missing path = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskInfoCurrentDir(t *testing.T) {
|
||||||
|
free, total, err := DiskInfo(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DiskInfo: %v", err)
|
||||||
|
}
|
||||||
|
if total <= 0 {
|
||||||
|
t.Errorf("total bytes should be > 0, got %d", total)
|
||||||
|
}
|
||||||
|
if free > total {
|
||||||
|
t.Errorf("free (%d) should not exceed total (%d)", free, total)
|
||||||
|
}
|
||||||
|
}
|
||||||
26
internal/agent/docker.go
Normal file
26
internal/agent/docker.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
216
internal/agent/downlink_test.go
Normal file
216
internal/agent/downlink_test.go
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
208
internal/agent/events_client.go
Normal file
208
internal/agent/events_client.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
193
internal/agent/events_client_test.go
Normal file
193
internal/agent/events_client_test.go
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
232
internal/agent/mirror_client.go
Normal file
232
internal/agent/mirror_client.go
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MirrorEntry mirrors the shape of /api/v1/mirrors items on the server.
|
||||||
|
type MirrorEntry struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Kind string `json:"kind"` // "clearnet" | "tor"
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorChannel is an out-of-band status channel (Telegram, status page, etc.)
|
||||||
|
type MirrorChannel struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorsResponse is the JSON document served by /api/v1/mirrors and
|
||||||
|
// /api/mirrors.
|
||||||
|
type MirrorsResponse struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Mirrors []MirrorEntry `json:"mirrors"`
|
||||||
|
Tor *MirrorEntry `json:"tor"`
|
||||||
|
Channels []MirrorChannel `json:"channels"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStaticFallbackURLs lists off-domain JSON copies of the mirror list.
|
||||||
|
// Hard-coded here (not loaded from config) because the whole point is to
|
||||||
|
// have something to consult when config-driven URLs all fail.
|
||||||
|
//
|
||||||
|
// Hosted on IPFS (content-addressed, re-pinnable, no host can take it down
|
||||||
|
// permanently — same bytes re-pinned anywhere keep the same CID). Multiple
|
||||||
|
// public gateways are listed so a single gateway being blocked doesn't kill
|
||||||
|
// the fallback; the /ipfs/<CID>/ path is identical across all gateways.
|
||||||
|
//
|
||||||
|
// GitHub Pages was removed 2026-05-17: the whole torrentclaw org is
|
||||||
|
// shadow-banned (public repos 404 to anonymous users). Do NOT re-add any
|
||||||
|
// github.io URL. Keep this slice in sync with `STATIC_FALLBACKS` in
|
||||||
|
// `torrentclaw-web/src/lib/mirrors-config.ts` — when the IPFS CID changes
|
||||||
|
// (scripts/publish-mirrors-ipfs.sh), update both.
|
||||||
|
//
|
||||||
|
// Future hardening: sign mirrors.json with the same ed25519 release key
|
||||||
|
// (or a sibling) so a hijack of any single static host cannot serve a
|
||||||
|
// malicious mirror list. Today the only signal is "agreement between
|
||||||
|
// independent providers" via cross-checking, which we leave to the
|
||||||
|
// operator.
|
||||||
|
const mirrorsIPFSCID = "bafybeigwux74fek7uky7nct47z5eqwwnpylakfxppqqnzbuxdw7p3ikfdy"
|
||||||
|
|
||||||
|
var DefaultStaticFallbackURLs = []string{
|
||||||
|
"https://ipfs.io/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
|
||||||
|
"https://dweb.link/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
|
||||||
|
"https://gateway.pinata.cloud/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against
|
||||||
|
// `candidates` first; if every candidate fails, it falls back to the static
|
||||||
|
// JSON copies on off-domain hosts (GitHub Pages, Cloudflare Pages, …).
|
||||||
|
//
|
||||||
|
// This is the function `unarr mirrors update` should call when it wants the
|
||||||
|
// strongest "give me a working mirror list no matter what" guarantee.
|
||||||
|
func FetchMirrorsWithFallback(ctx context.Context, candidates []string, userAgent string) (*MirrorsResponse, error) {
|
||||||
|
resp, err := FetchMirrors(ctx, candidates, userAgent)
|
||||||
|
if err == nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
if len(DefaultStaticFallbackURLs) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Try the static JSON files directly. They follow the same wire shape so
|
||||||
|
// we can reuse the same parser — but the URLs already include the JSON
|
||||||
|
// suffix so we hit them with `fetchMirrorsJSON` instead of FetchMirrors
|
||||||
|
// (which appends /api/v1/mirrors).
|
||||||
|
staticResp, staticErr := fetchMirrorsJSON(ctx, DefaultStaticFallbackURLs, userAgent)
|
||||||
|
if staticErr == nil {
|
||||||
|
return staticResp, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("primary failed (%v) and static fallback failed (%v)", err, staticErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs
|
||||||
|
// (e.g. https://ipfs.io/ipfs/<CID>/mirrors.json). Each candidate is tried
|
||||||
|
// in order; the first success wins.
|
||||||
|
func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) {
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return nil, fmt.Errorf("no static fallback URLs configured")
|
||||||
|
}
|
||||||
|
hc := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
var lastErr error
|
||||||
|
for _, url := range urls {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
lastErr = readErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
lastErr = fmt.Errorf("%s returned HTTP %d", url, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var out MirrorsResponse
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
lastErr = fmt.Errorf("%s: invalid JSON: %w", url, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(out.Mirrors) == 0 {
|
||||||
|
lastErr = fmt.Errorf("%s returned empty mirror list", url)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no reachable static fallback")
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// FetchMirrors pulls the latest mirror list from the server.
|
||||||
|
//
|
||||||
|
// The endpoint is intentionally public and unauthenticated: the whole point
|
||||||
|
// of mirror discovery is that it must work even when the user's API key
|
||||||
|
// is invalid, expired, or the auth path is unreachable. The function tries
|
||||||
|
// each candidate base URL in order so a takedown of the primary doesn't
|
||||||
|
// also kill mirror discovery.
|
||||||
|
func FetchMirrors(ctx context.Context, candidates []string, userAgent string) (*MirrorsResponse, error) {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil, fmt.Errorf("no mirror discovery URLs configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
hc := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, base := range candidates {
|
||||||
|
if base == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url := base + "/api/v1/mirrors"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if userAgent != "" {
|
||||||
|
req.Header.Set("User-Agent", userAgent)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
lastErr = readErr
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
lastErr = fmt.Errorf("%s returned HTTP %d", base, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var out MirrorsResponse
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
lastErr = fmt.Errorf("%s: invalid JSON: %w", base, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(out.Mirrors) == 0 {
|
||||||
|
lastErr = fmt.Errorf("%s returned empty mirror list", base)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr == nil {
|
||||||
|
lastErr = fmt.Errorf("no reachable mirror discovery endpoint")
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fetch mirrors: %w", lastErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToConfig splits a MirrorsResponse into (primary, extras) suitable for
|
||||||
|
// rebuilding a MirrorPool or persisting back into config.toml.
|
||||||
|
//
|
||||||
|
// The "primary" returned here is whichever entry has primary=true. If none
|
||||||
|
// are flagged, the first one wins.
|
||||||
|
func (m *MirrorsResponse) ToConfig() (primary string, extras []string) {
|
||||||
|
if m == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
var picked *MirrorEntry
|
||||||
|
for i := range m.Mirrors {
|
||||||
|
if m.Mirrors[i].Primary {
|
||||||
|
picked = &m.Mirrors[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if picked == nil && len(m.Mirrors) > 0 {
|
||||||
|
picked = &m.Mirrors[0]
|
||||||
|
}
|
||||||
|
if picked != nil {
|
||||||
|
primary = picked.URL
|
||||||
|
}
|
||||||
|
for _, e := range m.Mirrors {
|
||||||
|
if e.URL == primary {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
extras = append(extras, e.URL)
|
||||||
|
}
|
||||||
|
return primary, extras
|
||||||
|
}
|
||||||
172
internal/agent/mirror_pool.go
Normal file
172
internal/agent/mirror_pool.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MirrorPool holds the ordered list of API base URLs the client is willing to
|
||||||
|
// fall back to when the current mirror is unreachable. The first entry is
|
||||||
|
// always the "preferred" mirror configured by the user. Subsequent entries
|
||||||
|
// are alternate domains we can rotate to without changing any user-visible
|
||||||
|
// configuration — they exist so a long-lived agent survives a takedown of
|
||||||
|
// the primary host without needing a new release.
|
||||||
|
//
|
||||||
|
// The pool is concurrency-safe; rotation is a fast O(1) index bump under a
|
||||||
|
// mutex. The previously-active mirror is NEVER removed — it might just be
|
||||||
|
// temporarily unreachable from one network path.
|
||||||
|
type MirrorPool struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
mirrors []string
|
||||||
|
current int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMirrorPool builds a pool from the provided base URLs. The primary URL
|
||||||
|
// is always first; "extras" are appended in order and de-duplicated. Empty
|
||||||
|
// strings are skipped. Trailing slashes are normalised so callers can concat
|
||||||
|
// `pool.Current() + "/api/..."` reliably.
|
||||||
|
func NewMirrorPool(primary string, extras []string) *MirrorPool {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []string
|
||||||
|
|
||||||
|
add := func(raw string) {
|
||||||
|
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||||
|
if raw == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, dup := seen[raw]; dup {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[raw] = struct{}{}
|
||||||
|
out = append(out, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(primary)
|
||||||
|
for _, e := range extras {
|
||||||
|
add(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(out) == 0 {
|
||||||
|
// Defensive: always return a pool with at least one entry so callers
|
||||||
|
// can call Current() without nil checks. The empty string would
|
||||||
|
// produce obvious errors immediately, which is preferable to a panic
|
||||||
|
// somewhere deep in net/http.
|
||||||
|
out = []string{""}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MirrorPool{mirrors: out}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current returns the active base URL.
|
||||||
|
func (p *MirrorPool) Current() string {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return p.mirrors[p.current]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mirrors returns a copy of the configured base URLs in priority order.
|
||||||
|
func (p *MirrorPool) Mirrors() []string {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
out := make([]string, len(p.mirrors))
|
||||||
|
copy(out, p.mirrors)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len reports how many mirrors are configured.
|
||||||
|
func (p *MirrorPool) Len() int {
|
||||||
|
p.mu.RLock()
|
||||||
|
defer p.mu.RUnlock()
|
||||||
|
return len(p.mirrors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate moves the cursor to the next mirror in the pool, wrapping around.
|
||||||
|
// Returns the new current mirror and whether a rotation actually happened
|
||||||
|
// (a single-mirror pool returns false).
|
||||||
|
func (p *MirrorPool) Rotate() (string, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if len(p.mirrors) <= 1 {
|
||||||
|
return p.mirrors[p.current], false
|
||||||
|
}
|
||||||
|
p.current = (p.current + 1) % len(p.mirrors)
|
||||||
|
return p.mirrors[p.current], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace swaps the entire mirror set, e.g. after `unarr mirrors update`
|
||||||
|
// downloaded a fresh list from /api/v1/mirrors. Resets the cursor to 0 so
|
||||||
|
// the newly-discovered primary is tried first.
|
||||||
|
func (p *MirrorPool) Replace(primary string, extras []string) {
|
||||||
|
fresh := NewMirrorPool(primary, extras)
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
p.mirrors = fresh.mirrors
|
||||||
|
p.current = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTransient reports whether an error is the kind we should retry against
|
||||||
|
// another mirror. The intent is conservative: rotate on connection-level
|
||||||
|
// failures (DNS, refused, TLS, timeouts, 5xx) but NOT on auth or validation
|
||||||
|
// errors that would just fail again somewhere else.
|
||||||
|
func IsTransient(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var httpErr *HTTPError
|
||||||
|
if errors.As(err, &httpErr) {
|
||||||
|
switch httpErr.StatusCode {
|
||||||
|
case http.StatusBadGateway,
|
||||||
|
http.StatusServiceUnavailable,
|
||||||
|
http.StatusGatewayTimeout,
|
||||||
|
http.StatusRequestTimeout:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 4xx (auth, rate limit, validation) won't get healthier on another mirror.
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if errors.Is(err, context.DeadlineExceeded) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var dnsErr *net.DNSError
|
||||||
|
if errors.As(err, &dnsErr) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlErr *url.Error
|
||||||
|
if errors.As(err, &urlErr) {
|
||||||
|
// `connection refused`, `EOF`, `tls: ...` end up as wrapped url.Errors.
|
||||||
|
msg := urlErr.Error()
|
||||||
|
if strings.Contains(msg, "connection refused") ||
|
||||||
|
strings.Contains(msg, "no such host") ||
|
||||||
|
strings.Contains(msg, "EOF") ||
|
||||||
|
strings.Contains(msg, "tls:") ||
|
||||||
|
strings.Contains(msg, "i/o timeout") ||
|
||||||
|
strings.Contains(msg, "network is unreachable") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bare strings as last resort — net.OpError messages are unstable across Go versions.
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "connection refused") ||
|
||||||
|
strings.Contains(msg, "no such host") ||
|
||||||
|
strings.Contains(msg, "i/o timeout") ||
|
||||||
|
strings.Contains(msg, "network is unreachable") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
88
internal/agent/mirror_transport.go
Normal file
88
internal/agent/mirror_transport.go
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
172
internal/agent/mirror_transport_test.go
Normal file
172
internal/agent/mirror_transport_test.go
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
22
internal/agent/process_unix_test.go
Normal file
22
internal/agent/process_unix_test.go
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsProcessAliveSelf(t *testing.T) {
|
||||||
|
if !IsProcessAlive(os.Getpid()) {
|
||||||
|
t.Errorf("self PID should be alive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsProcessAliveBogus(t *testing.T) {
|
||||||
|
// PID 0 is reserved (signal 0 to PID 0 broadcasts to the whole pgrp).
|
||||||
|
// Pick a very high PID unlikely to exist.
|
||||||
|
if IsProcessAlive(0x7FFFFFFE) {
|
||||||
|
t.Errorf("very high PID should not be alive")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -9,6 +11,13 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"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.
|
// DaemonState is written to disk every heartbeat for external tools to read.
|
||||||
type DaemonState struct {
|
type DaemonState struct {
|
||||||
AgentID string `json:"agentId"`
|
AgentID string `json:"agentId"`
|
||||||
|
|
@ -22,6 +31,18 @@ type DaemonState struct {
|
||||||
FailedCount int `json:"failedCount"`
|
FailedCount int `json:"failedCount"`
|
||||||
TotalDownloaded int64 `json:"totalDownloaded"`
|
TotalDownloaded int64 `json:"totalDownloaded"`
|
||||||
MethodStats map[string]int `json:"methodStats,omitempty"`
|
MethodStats map[string]int `json:"methodStats,omitempty"`
|
||||||
|
|
||||||
|
// Managed-VPN split-tunnel state, so `unarr vpn status` can report whether
|
||||||
|
// torrent traffic is actually being routed through the tunnel (vs. the daemon
|
||||||
|
// running but the tunnel having failed to come up → downloading in the clear).
|
||||||
|
VPNActive bool `json:"vpnActive,omitempty"`
|
||||||
|
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
|
||||||
|
VPNServer string `json:"vpnServer,omitempty"` // WireGuard endpoint (ip:port)
|
||||||
|
|
||||||
|
// CloudFlare Quick Tunnel state, so `unarr funnel status` can report the
|
||||||
|
// HTTPS hostname the daemon is reachable at from anywhere on the internet.
|
||||||
|
// Empty when the funnel is off or hasn't registered yet.
|
||||||
|
FunnelURL string `json:"funnelUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// stateFilePathFn is overridable for testing.
|
// stateFilePathFn is overridable for testing.
|
||||||
|
|
@ -45,25 +66,43 @@ func WriteState(state *DaemonState) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write to temp file then rename for atomicity
|
// Write to temp file then rename for atomicity. 0o600 keeps the file
|
||||||
|
// readable only by the owning user — the state contains agentID, PID
|
||||||
|
// and counters which are useful to a co-tenant on a shared host for
|
||||||
|
// fingerprinting the daemon, and we already use 0o600 for the config
|
||||||
|
// file. No need for cross-user readability here.
|
||||||
tmp := path + ".tmp"
|
tmp := path + ".tmp"
|
||||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
if err := os.WriteFile(tmp, data, 0o600); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
os.Rename(tmp, path)
|
os.Rename(tmp, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadState reads the daemon state from disk. Returns nil if not found.
|
// ReadState reads the daemon state from disk. Returns nil if not found or
|
||||||
|
// unreadable. Use LoadState when callers need to distinguish "not running"
|
||||||
|
// from "state file corrupted".
|
||||||
func ReadState() *DaemonState {
|
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())
|
data, err := os.ReadFile(StateFilePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrDaemonNotRunning
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
var state DaemonState
|
var state DaemonState
|
||||||
if json.Unmarshal(data, &state) != nil {
|
if err := json.Unmarshal(data, &state); err != nil {
|
||||||
return nil
|
return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
|
||||||
}
|
}
|
||||||
return &state
|
return &state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveState deletes the state file (called on clean shutdown).
|
// RemoveState deletes the state file (called on clean shutdown).
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
|
||||||
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,11 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -14,6 +17,23 @@ const (
|
||||||
// SyncIntervalIdle is the sync interval when nobody is watching.
|
// SyncIntervalIdle is the sync interval when nobody is watching.
|
||||||
// Keep this short enough to pick up stream requests quickly without hammering the server.
|
// Keep this short enough to pick up stream requests quickly without hammering the server.
|
||||||
SyncIntervalIdle = 10 * time.Second
|
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.
|
// SyncClient handles bidirectional state synchronization between the CLI and server.
|
||||||
|
|
@ -28,18 +48,41 @@ type SyncClient struct {
|
||||||
OnNewTasks func(tasks []Task)
|
OnNewTasks func(tasks []Task)
|
||||||
OnControl func(action, taskID string, deleteFiles bool)
|
OnControl func(action, taskID string, deleteFiles bool)
|
||||||
OnStreamRequest func(req StreamRequest)
|
OnStreamRequest func(req StreamRequest)
|
||||||
|
OnStreamSession func(sess StreamSession)
|
||||||
OnUpgrade func(version string)
|
OnUpgrade func(version string)
|
||||||
OnScan func()
|
OnScan func()
|
||||||
OnWatchingChange func(watching bool)
|
OnWatchingChange func(watching bool)
|
||||||
OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
|
OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
|
||||||
GetFreeSlots func() int
|
GetFreeSlots func() int
|
||||||
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks
|
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks
|
||||||
|
// GetVPNState returns the live managed-VPN split-tunnel state (whether the
|
||||||
|
// 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)
|
||||||
|
// 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
|
||||||
|
// OnDeleteFiles is called when the server requests file deletion from disk.
|
||||||
|
// It should delete the files and return the IDs of successfully deleted items.
|
||||||
|
OnDeleteFiles func(items []LibraryDeleteRequest) []int
|
||||||
|
|
||||||
// SyncNow triggers an immediate sync (e.g., on task completion).
|
// SyncNow triggers an immediate sync (e.g., on task completion).
|
||||||
SyncNow chan struct{}
|
SyncNow chan struct{}
|
||||||
|
|
||||||
watching atomic.Bool
|
watching atomic.Bool
|
||||||
interval atomic.Int64 // stored as nanoseconds
|
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{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSyncClient creates a sync client.
|
// NewSyncClient creates a sync client.
|
||||||
|
|
@ -49,6 +92,7 @@ func NewSyncClient(client *Client, cfg DaemonConfig, state *LocalState) *SyncCli
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
state: state,
|
state: state,
|
||||||
SyncNow: make(chan struct{}, 1),
|
SyncNow: make(chan struct{}, 1),
|
||||||
|
livenessTimeout: downlinkLivenessTimeout,
|
||||||
}
|
}
|
||||||
sc.interval.Store(int64(SyncIntervalIdle))
|
sc.interval.Store(int64(SyncIntervalIdle))
|
||||||
return sc
|
return sc
|
||||||
|
|
@ -69,8 +113,9 @@ func (sc *SyncClient) TriggerSync() {
|
||||||
|
|
||||||
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
|
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
|
||||||
func (sc *SyncClient) Run(ctx context.Context) error {
|
func (sc *SyncClient) Run(ctx context.Context) error {
|
||||||
// Start wake listener in background — triggers immediate syncs on demand.
|
// Start the realtime downlink in background — pushes immediate syncs +
|
||||||
go sc.runWakeListener(ctx)
|
// typed control commands on demand (SSE-first, long-poll fallback).
|
||||||
|
go sc.runDownlink(ctx)
|
||||||
|
|
||||||
// Initial sync immediately
|
// Initial sync immediately
|
||||||
sc.doSync(ctx)
|
sc.doSync(ctx)
|
||||||
|
|
@ -129,6 +174,8 @@ func (sc *SyncClient) buildRequest() SyncRequest {
|
||||||
StreamPort: sc.cfg.StreamPort,
|
StreamPort: sc.cfg.StreamPort,
|
||||||
LanIP: sc.cfg.LanIP,
|
LanIP: sc.cfg.LanIP,
|
||||||
TailscaleIP: sc.cfg.TailscaleIP,
|
TailscaleIP: sc.cfg.TailscaleIP,
|
||||||
|
CanDelete: sc.cfg.CanDelete,
|
||||||
|
IsDocker: RunningInDocker(),
|
||||||
}
|
}
|
||||||
if sc.GetTaskStates != nil {
|
if sc.GetTaskStates != nil {
|
||||||
req.Tasks = sc.GetTaskStates()
|
req.Tasks = sc.GetTaskStates()
|
||||||
|
|
@ -142,6 +189,24 @@ func (sc *SyncClient) buildRequest() SyncRequest {
|
||||||
if sc.GetFreeSlots != nil {
|
if sc.GetFreeSlots != nil {
|
||||||
req.FreeSlots = sc.GetFreeSlots()
|
req.FreeSlots = sc.GetFreeSlots()
|
||||||
}
|
}
|
||||||
|
if sc.GetVPNState != nil {
|
||||||
|
req.VPNActive, req.VPNMode, req.VPNServer = sc.GetVPNState()
|
||||||
|
}
|
||||||
|
if sc.GetFunnelURL != nil {
|
||||||
|
req.FunnelURL = sc.GetFunnelURL()
|
||||||
|
}
|
||||||
|
// Flush confirmed deletions from previous cycle.
|
||||||
|
// Once flushed, remove IDs from deleteInFlight — the server will stop sending
|
||||||
|
// them after this sync, so deduplication protection is no longer needed.
|
||||||
|
sc.pendingDeleteMu.Lock()
|
||||||
|
if len(sc.pendingDeleteConfirmed) > 0 {
|
||||||
|
req.DeleteConfirmed = sc.pendingDeleteConfirmed
|
||||||
|
for _, id := range sc.pendingDeleteConfirmed {
|
||||||
|
delete(sc.deleteInFlight, id)
|
||||||
|
}
|
||||||
|
sc.pendingDeleteConfirmed = nil
|
||||||
|
}
|
||||||
|
sc.pendingDeleteMu.Unlock()
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,6 +232,13 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HLS streaming sessions.
|
||||||
|
for _, ws := range resp.StreamSessions {
|
||||||
|
if sc.OnStreamSession != nil {
|
||||||
|
sc.OnStreamSession(ws)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Upgrade
|
// Upgrade
|
||||||
if resp.Upgrade != nil && resp.Upgrade.Version != "" && sc.OnUpgrade != nil {
|
if resp.Upgrade != nil && resp.Upgrade.Version != "" && sc.OnUpgrade != nil {
|
||||||
sc.OnUpgrade(resp.Upgrade.Version)
|
sc.OnUpgrade(resp.Upgrade.Version)
|
||||||
|
|
@ -176,6 +248,35 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
|
||||||
if resp.Scan && sc.OnScan != nil {
|
if resp.Scan && sc.OnScan != nil {
|
||||||
sc.OnScan()
|
sc.OnScan()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// File deletions requested by the server — deduplicate against in-flight items
|
||||||
|
if len(resp.FilesToDelete) > 0 && sc.OnDeleteFiles != nil {
|
||||||
|
sc.pendingDeleteMu.Lock()
|
||||||
|
if sc.deleteInFlight == nil {
|
||||||
|
sc.deleteInFlight = make(map[int]struct{})
|
||||||
|
}
|
||||||
|
var newItems []LibraryDeleteRequest
|
||||||
|
for _, item := range resp.FilesToDelete {
|
||||||
|
if _, inFlight := sc.deleteInFlight[item.ItemID]; !inFlight {
|
||||||
|
newItems = append(newItems, item)
|
||||||
|
sc.deleteInFlight[item.ItemID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sc.pendingDeleteMu.Unlock()
|
||||||
|
|
||||||
|
if len(newItems) > 0 {
|
||||||
|
// Run deletions off the sync goroutine — disk I/O must not block the
|
||||||
|
// next sync tick. Confirmations are picked up on the next regular cycle.
|
||||||
|
go func(items []LibraryDeleteRequest) {
|
||||||
|
confirmed := sc.OnDeleteFiles(items)
|
||||||
|
if len(confirmed) > 0 {
|
||||||
|
sc.pendingDeleteMu.Lock()
|
||||||
|
sc.pendingDeleteConfirmed = append(sc.pendingDeleteConfirmed, confirmed...)
|
||||||
|
sc.pendingDeleteMu.Unlock()
|
||||||
|
}
|
||||||
|
}(newItems)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.
|
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.
|
||||||
|
|
@ -210,6 +311,176 @@ 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) {
|
func (sc *SyncClient) adjustInterval(watching bool) {
|
||||||
prev := sc.watching.Load()
|
prev := sc.watching.Load()
|
||||||
sc.watching.Store(watching)
|
sc.watching.Store(watching)
|
||||||
|
|
|
||||||
|
|
@ -215,3 +215,56 @@ func TestLocalState_EmptySnapshot(t *testing.T) {
|
||||||
t.Errorf("expected 0 tasks, got %d", len(snap))
|
t.Errorf("expected 0 tasks, got %d", len(snap))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskStateFromUpdate(t *testing.T) {
|
||||||
|
u := StatusUpdate{
|
||||||
|
TaskID: "task-1",
|
||||||
|
Status: "downloading",
|
||||||
|
Progress: 42,
|
||||||
|
DownloadedBytes: 1024,
|
||||||
|
TotalBytes: 4096,
|
||||||
|
SpeedBps: 100,
|
||||||
|
ETA: 30,
|
||||||
|
ResolvedMethod: "torrent",
|
||||||
|
FileName: "movie.mkv",
|
||||||
|
FilePath: "/tmp/movie.mkv",
|
||||||
|
StreamURL: "http://localhost/stream",
|
||||||
|
ErrorMessage: "",
|
||||||
|
}
|
||||||
|
got := TaskStateFromUpdate(u)
|
||||||
|
if got.TaskID != "task-1" || got.Status != "downloading" || got.Progress != 42 {
|
||||||
|
t.Errorf("basic fields wrong: %+v", got)
|
||||||
|
}
|
||||||
|
if got.DownloadedBytes != 1024 || got.TotalBytes != 4096 || got.SpeedBps != 100 {
|
||||||
|
t.Errorf("byte fields wrong: %+v", got)
|
||||||
|
}
|
||||||
|
if got.ResolvedMethod != "torrent" || got.FileName != "movie.mkv" {
|
||||||
|
t.Errorf("method/name fields wrong: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortID(t *testing.T) {
|
||||||
|
if got := ShortID("abcdef1234567890"); got != "abcdef12" {
|
||||||
|
t.Errorf("ShortID = %q", got)
|
||||||
|
}
|
||||||
|
if got := ShortID("short"); got != "short" {
|
||||||
|
t.Errorf("ShortID short = %q", got)
|
||||||
|
}
|
||||||
|
if got := ShortID(""); got != "" {
|
||||||
|
t.Errorf("ShortID empty = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateFilePath(t *testing.T) {
|
||||||
|
if got := StateFilePath(); got == "" {
|
||||||
|
t.Errorf("StateFilePath should not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHTTPError(t *testing.T) {
|
||||||
|
e := &HTTPError{StatusCode: 404, Message: "not found"}
|
||||||
|
got := e.Error()
|
||||||
|
if got == "" || got == "API error 0: " {
|
||||||
|
t.Errorf("HTTPError.Error() unexpected: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,44 @@ type RegisterRequest struct {
|
||||||
StreamPort int `json:"streamPort,omitempty"`
|
StreamPort int `json:"streamPort,omitempty"`
|
||||||
LanIP string `json:"lanIp,omitempty"`
|
LanIP string `json:"lanIp,omitempty"`
|
||||||
TailscaleIP string `json:"tailscaleIp,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"`
|
||||||
|
// 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
|
||||||
|
// the largest output resolution the agent can encode comfortably; for
|
||||||
|
// software-only ffmpeg this is 1080p, with a real GPU encoder it goes
|
||||||
|
// 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.
|
||||||
|
// VPNActive has no omitempty: false is a meaningful state (tunnel down), not
|
||||||
|
// "unset" — the server must see it to release the slot.
|
||||||
|
VPNActive bool `json:"vpnActive"`
|
||||||
|
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
|
||||||
|
VPNServer string `json:"vpnServer,omitempty"`
|
||||||
|
// 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.
|
// RegisterResponse is returned by the server after registration.
|
||||||
|
|
@ -72,6 +110,12 @@ type Task struct {
|
||||||
Episode *int `json:"episode,omitempty"` // Episode number
|
Episode *int `json:"episode,omitempty"` // Episode number
|
||||||
ContentYear *int `json:"contentYear,omitempty"` // Year from TMDB (avoids regex on torrent title)
|
ContentYear *int `json:"contentYear,omitempty"` // Year from TMDB (avoids regex on torrent title)
|
||||||
CollectionName string `json:"collectionName,omitempty"` // Collection name (e.g., "Harry Potter Collection")
|
CollectionName string `json:"collectionName,omitempty"` // Collection name (e.g., "Harry Potter Collection")
|
||||||
|
|
||||||
|
// FilePath is the on-disk path of the file the agent is being asked
|
||||||
|
// to operate on. Currently used by mode=seed_file to know which
|
||||||
|
// arbitrary file to wrap as a single-file torrent for browser
|
||||||
|
// streaming; populated by the server from libraryItem.filePath.
|
||||||
|
FilePath string `json:"filePath,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamRequest is a request to stream a completed download from disk.
|
// StreamRequest is a request to stream a completed download from disk.
|
||||||
|
|
@ -95,6 +139,14 @@ type StatusUpdate struct {
|
||||||
StreamURL string `json:"streamUrl,omitempty"`
|
StreamURL string `json:"streamUrl,omitempty"`
|
||||||
StreamReady bool `json:"streamReady,omitempty"`
|
StreamReady bool `json:"streamReady,omitempty"`
|
||||||
ErrorMessage string `json:"errorMessage,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/<hash>.
|
||||||
|
InfoHash string `json:"infoHash,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// StatusResponse is returned by the status endpoint.
|
// StatusResponse is returned by the status endpoint.
|
||||||
|
|
@ -271,6 +323,7 @@ type DebridAccount struct {
|
||||||
type LibrarySyncRequest struct {
|
type LibrarySyncRequest struct {
|
||||||
Items []LibrarySyncItem `json:"items"`
|
Items []LibrarySyncItem `json:"items"`
|
||||||
ScanPath string `json:"scanPath"`
|
ScanPath string `json:"scanPath"`
|
||||||
|
AgentID string `json:"agentId,omitempty"` // lets the server scope stale-cleanup per agent
|
||||||
IsLastBatch bool `json:"isLastBatch"`
|
IsLastBatch bool `json:"isLastBatch"`
|
||||||
SyncStartedAt string `json:"syncStartedAt,omitempty"` // ISO-8601; same for all batches in a session
|
SyncStartedAt string `json:"syncStartedAt,omitempty"` // ISO-8601; same for all batches in a session
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +349,17 @@ type LibrarySyncItem struct {
|
||||||
AudioTracks any `json:"audioTracks,omitempty"`
|
AudioTracks any `json:"audioTracks,omitempty"`
|
||||||
SubtitleTracks any `json:"subtitleTracks,omitempty"`
|
SubtitleTracks any `json:"subtitleTracks,omitempty"`
|
||||||
VideoInfo any `json:"videoInfo,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.
|
// LibrarySyncResponse is returned after syncing library items.
|
||||||
|
|
@ -325,6 +389,20 @@ type SyncRequest struct {
|
||||||
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
TailscaleIP string `json:"tailscaleIp,omitempty"`
|
||||||
FreeSlots int `json:"freeSlots"`
|
FreeSlots int `json:"freeSlots"`
|
||||||
Tasks []TaskState `json:"tasks"`
|
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
|
||||||
|
// 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
|
||||||
|
// releases the slot, not be elided as "unset".
|
||||||
|
VPNActive bool `json:"vpnActive"`
|
||||||
|
VPNMode string `json:"vpnMode,omitempty"`
|
||||||
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ControlAction represents a server-side control signal for a task.
|
// ControlAction represents a server-side control signal for a task.
|
||||||
|
|
@ -334,14 +412,65 @@ type ControlAction struct {
|
||||||
DeleteFiles bool `json:"deleteFiles,omitempty"`
|
DeleteFiles bool `json:"deleteFiles,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LibraryDeleteRequest is a server-side request to delete a file from disk.
|
||||||
|
type LibraryDeleteRequest struct {
|
||||||
|
ItemID int `json:"itemId"`
|
||||||
|
FilePath string `json:"filePath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamSession is a request to open an HLS streaming session for an
|
||||||
|
// in-browser player. The CLI registers the HLS session in the StreamServer's
|
||||||
|
// 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"`
|
||||||
|
// 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"`
|
||||||
|
// 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"`
|
||||||
|
// 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.
|
// SyncResponse is returned by the server with all pending actions for the CLI.
|
||||||
type SyncResponse struct {
|
type SyncResponse struct {
|
||||||
NewTasks []Task `json:"newTasks,omitempty"`
|
NewTasks []Task `json:"newTasks,omitempty"`
|
||||||
Controls []ControlAction `json:"controls,omitempty"`
|
Controls []ControlAction `json:"controls,omitempty"`
|
||||||
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
|
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
|
||||||
|
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
|
||||||
Watching bool `json:"watching"`
|
Watching bool `json:"watching"`
|
||||||
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
|
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
|
||||||
Scan bool `json:"scan,omitempty"`
|
Scan bool `json:"scan,omitempty"`
|
||||||
|
FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
23
internal/cmd/agent_client.go
Normal file
23
internal/cmd/agent_client.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newAgentClientFromConfig builds an agent.Client wired with the mirror pool
|
||||||
|
// from the user's TOML config. Use this instead of agent.NewClient in any
|
||||||
|
// long-running command (daemon, status loop, etc.) so a `.com` outage rolls
|
||||||
|
// over to `.to` / .onion without restarting the agent.
|
||||||
|
//
|
||||||
|
// The function lives in cmd/ rather than agent/ because it has to know
|
||||||
|
// about the config struct, and cmd/ is the only place that owns the
|
||||||
|
// "wire defaults + user overrides" rule.
|
||||||
|
func newAgentClientFromConfig(cfg config.Config, userAgent string) *agent.Client {
|
||||||
|
return agent.NewClientWithMirrors(
|
||||||
|
cfg.Auth.APIURL,
|
||||||
|
cfg.Auth.Mirrors,
|
||||||
|
cfg.Auth.APIKey,
|
||||||
|
userAgent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"}
|
var configCategories = []string{"downloads", "organization", "library", "notifications", "device", "region", "connection", "advanced"}
|
||||||
|
|
||||||
func newConfigCmd() *cobra.Command {
|
func newConfigCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|
@ -25,6 +25,7 @@ func newConfigCmd() *cobra.Command {
|
||||||
Categories:
|
Categories:
|
||||||
downloads Download directory, method, speed limits, concurrency
|
downloads Download directory, method, speed limits, concurrency
|
||||||
organization Auto-sort into Movies / TV Shows folders
|
organization Auto-sort into Movies / TV Shows folders
|
||||||
|
library Library scan settings and file deletion permissions
|
||||||
notifications Desktop notifications
|
notifications Desktop notifications
|
||||||
device Agent name
|
device Agent name
|
||||||
region Country and language
|
region Country and language
|
||||||
|
|
@ -95,6 +96,7 @@ func runConfigMenu(category string) error {
|
||||||
Options(
|
Options(
|
||||||
huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
|
huh.NewOption("Downloads — directory, method, speed limits", "downloads"),
|
||||||
huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"),
|
huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"),
|
||||||
|
huh.NewOption("Library — scan settings & file deletion", "library"),
|
||||||
huh.NewOption("Notifications — desktop notifications", "notifications"),
|
huh.NewOption("Notifications — desktop notifications", "notifications"),
|
||||||
huh.NewOption("Device — agent name", "device"),
|
huh.NewOption("Device — agent name", "device"),
|
||||||
huh.NewOption("Region — country & language", "region"),
|
huh.NewOption("Region — country & language", "region"),
|
||||||
|
|
@ -131,6 +133,8 @@ func runCategory(cfg *config.Config, category string) error {
|
||||||
return configDownloads(cfg)
|
return configDownloads(cfg)
|
||||||
case "organization":
|
case "organization":
|
||||||
return configOrganization(cfg)
|
return configOrganization(cfg)
|
||||||
|
case "library":
|
||||||
|
return configLibrary(cfg)
|
||||||
case "notifications":
|
case "notifications":
|
||||||
return configNotifications(cfg)
|
return configNotifications(cfg)
|
||||||
case "device":
|
case "device":
|
||||||
|
|
@ -311,6 +315,25 @@ func configConnection(cfg *config.Config) error {
|
||||||
).Run()
|
).Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func configLibrary(cfg *config.Config) error {
|
||||||
|
return huh.NewForm(
|
||||||
|
huh.NewGroup(
|
||||||
|
huh.NewConfirm().
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
func configAdvanced(_ *config.Config) error {
|
func configAdvanced(_ *config.Config) error {
|
||||||
// Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed.
|
// Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed.
|
||||||
fmt.Println("No advanced settings to configure. Sync intervals are automatic.")
|
fmt.Println("No advanced settings to configure. Sync intervals are automatic.")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
335
internal/cmd/daemon_control.go
Normal file
335
internal/cmd/daemon_control.go
Normal file
|
|
@ -0,0 +1,335 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDaemonStartCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "start",
|
||||||
|
Short: "Start the installed daemon service",
|
||||||
|
Long: `Start the unarr daemon using the system service manager.
|
||||||
|
Requires 'unarr daemon install' to have been run first.
|
||||||
|
|
||||||
|
Linux: systemctl --user start unarr
|
||||||
|
macOS: launchctl load ~/Library/LaunchAgents/com.torrentclaw.unarr.plist
|
||||||
|
Windows: schtasks /run /tn unarr`,
|
||||||
|
Example: ` unarr daemon start`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDaemonSvcStart()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDaemonStopCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "stop",
|
||||||
|
Short: "Stop the running daemon service",
|
||||||
|
Long: `Stop the unarr daemon service.
|
||||||
|
|
||||||
|
Linux: systemctl --user stop unarr
|
||||||
|
macOS: launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist
|
||||||
|
Windows: sends stop signal via process PID`,
|
||||||
|
Example: ` unarr daemon stop`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDaemonSvcStop()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDaemonRestartCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "restart",
|
||||||
|
Short: "Restart the daemon service",
|
||||||
|
Long: `Restart the unarr daemon service.
|
||||||
|
|
||||||
|
Linux: systemctl --user restart unarr
|
||||||
|
macOS: unload + reload launchd agent
|
||||||
|
Windows: stop by PID + schtasks /run`,
|
||||||
|
Example: ` unarr daemon restart`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDaemonSvcRestart()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDaemonSvcStatusCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show daemon service status",
|
||||||
|
Long: `Show the current status of the unarr daemon service as reported
|
||||||
|
by the system service manager, plus local state information.`,
|
||||||
|
Example: ` unarr daemon status`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDaemonSvcStatus()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDaemonLogsCmd() *cobra.Command {
|
||||||
|
var follow bool
|
||||||
|
var lines int
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logs",
|
||||||
|
Short: "Show daemon logs",
|
||||||
|
Long: `Show daemon log output.
|
||||||
|
|
||||||
|
Linux: streams from journald (journalctl --user -u unarr)
|
||||||
|
macOS: tails ~/.local/share/unarr/unarr.log
|
||||||
|
Windows: tails %LOCALAPPDATA%\unarr\unarr.log`,
|
||||||
|
Example: ` unarr daemon logs
|
||||||
|
unarr daemon logs -f
|
||||||
|
unarr daemon logs -n 100 -f`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDaemonLogs(follow, lines)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
|
||||||
|
cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDaemonReloadCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "reload",
|
||||||
|
Short: "Reload daemon configuration without restarting",
|
||||||
|
Long: `Send a reload signal to the running daemon, causing it to
|
||||||
|
re-read its configuration file without interrupting active downloads.
|
||||||
|
|
||||||
|
Linux/macOS: sends SIGUSR1 to the daemon process
|
||||||
|
Windows: not supported (use 'unarr daemon restart' instead)`,
|
||||||
|
Example: ` unarr daemon reload`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDaemonReload()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform implementations ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func runDaemonSvcStart() error {
|
||||||
|
fmt.Println()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
if err := svcExec("systemctl", "--user", "start", "unarr"); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.")
|
||||||
|
return fmt.Errorf("start service: %w", err)
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
plist := launchdPlistPath(home)
|
||||||
|
if _, err := os.Stat(plist); err != nil {
|
||||||
|
return fmt.Errorf("service not installed — run 'unarr daemon install' first")
|
||||||
|
}
|
||||||
|
if err := svcExec("launchctl", "load", plist); err != nil {
|
||||||
|
return fmt.Errorf("load service: %w", err)
|
||||||
|
}
|
||||||
|
case "windows":
|
||||||
|
if err := svcExec("schtasks", "/run", "/tn", "unarr"); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.")
|
||||||
|
return fmt.Errorf("start task: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("service control not supported on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
color.New(color.FgGreen).Println(" ✓ Started")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonSvcStop() error {
|
||||||
|
fmt.Println()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
if err := svcExec("systemctl", "--user", "stop", "unarr"); err != nil {
|
||||||
|
return fmt.Errorf("stop service: %w", err)
|
||||||
|
}
|
||||||
|
case "darwin":
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
plist := launchdPlistPath(home)
|
||||||
|
if err := svcExec("launchctl", "unload", plist); err != nil {
|
||||||
|
return fmt.Errorf("unload service: %w", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return stopDaemonByPID()
|
||||||
|
}
|
||||||
|
|
||||||
|
color.New(color.FgGreen).Println(" ✓ Stopped")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonSvcRestart() error {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
fmt.Println()
|
||||||
|
if err := svcExec("systemctl", "--user", "restart", "unarr"); err != nil {
|
||||||
|
return fmt.Errorf("restart service: %w", err)
|
||||||
|
}
|
||||||
|
color.New(color.FgGreen).Println(" ✓ Restarted")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
fmt.Println(" Stopping...")
|
||||||
|
_ = runDaemonSvcStop()
|
||||||
|
fmt.Println(" Starting...")
|
||||||
|
return runDaemonSvcStart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonSvcStatus() error {
|
||||||
|
fmt.Println()
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
// systemctl gives rich formatted output; exit code non-zero when stopped is fine.
|
||||||
|
svcExec("systemctl", "--user", "status", "--no-pager", "unarr") //nolint:errcheck
|
||||||
|
case "darwin":
|
||||||
|
printDaemonStatusDarwin()
|
||||||
|
case "windows":
|
||||||
|
svcExec("schtasks", "/query", "/tn", "unarr", "/fo", "LIST") //nolint:errcheck
|
||||||
|
default:
|
||||||
|
fmt.Printf(" Service manager not supported on %s\n", runtime.GOOS)
|
||||||
|
}
|
||||||
|
|
||||||
|
printStateInfo()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonLogs(follow bool, lines int) error {
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux":
|
||||||
|
args := []string{"--user", "-u", "unarr", "--no-pager", "-n", strconv.Itoa(lines)}
|
||||||
|
if follow {
|
||||||
|
// -f implies live output; drop --no-pager so journalctl can control the terminal.
|
||||||
|
args = []string{"--user", "-u", "unarr", "-f"}
|
||||||
|
}
|
||||||
|
return svcExecInteractive("journalctl", args...)
|
||||||
|
|
||||||
|
case "darwin":
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
logFile := filepath.Join(home, ".local", "share", "unarr", "unarr.log")
|
||||||
|
if _, err := os.Stat(logFile); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "The daemon writes this file when running as a launchd service. Run 'unarr daemon install' first.")
|
||||||
|
return fmt.Errorf("log file not found: %s", logFile)
|
||||||
|
}
|
||||||
|
args := []string{"-n", strconv.Itoa(lines)}
|
||||||
|
if follow {
|
||||||
|
args = append(args, "-f")
|
||||||
|
}
|
||||||
|
args = append(args, logFile)
|
||||||
|
return svcExecInteractive("tail", args...)
|
||||||
|
|
||||||
|
case "windows":
|
||||||
|
logFile := filepath.Join(config.DataDir(), "unarr.log")
|
||||||
|
if _, err := os.Stat(logFile); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "The daemon writes logs here when running. Start it first.")
|
||||||
|
return fmt.Errorf("log file not found: %s", logFile)
|
||||||
|
}
|
||||||
|
var psCmd string
|
||||||
|
if follow {
|
||||||
|
psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d -Wait", logFile, lines)
|
||||||
|
} else {
|
||||||
|
psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d", logFile, lines)
|
||||||
|
}
|
||||||
|
return svcExecInteractive("powershell", "-NonInteractive", "-Command", psCmd)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("log viewing not supported on %s", runtime.GOOS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDaemonReload() error {
|
||||||
|
return sendReloadSignal()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
return killPID(state.PID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func launchdPlistPath(home string) string {
|
||||||
|
return filepath.Join(home, "Library", "LaunchAgents", "com.torrentclaw.unarr.plist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// printDaemonStatusDarwin shows launchd service state by filtering launchctl output.
|
||||||
|
func printDaemonStatusDarwin() {
|
||||||
|
out, err := exec.Command("launchctl", "list").Output()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" Could not query launchctl: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if strings.Contains(line, "unarr") {
|
||||||
|
// Format: PID ExitCode Label
|
||||||
|
fmt.Printf(" launchd: %s\n", strings.TrimSpace(line))
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
fmt.Println(" launchd: service not loaded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printStateInfo shows information from the local daemon.state.json file.
|
||||||
|
func printStateInfo() {
|
||||||
|
state := agent.ReadState()
|
||||||
|
if state == nil {
|
||||||
|
color.New(color.FgHiBlack).Println(" State: no state file (daemon not running or crashed)")
|
||||||
|
fmt.Println()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dim := color.New(color.FgHiBlack)
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" Local state:")
|
||||||
|
fmt.Printf(" PID: %d\n", state.PID)
|
||||||
|
fmt.Printf(" Status: %s\n", state.Status)
|
||||||
|
fmt.Printf(" Version: %s\n", state.Version)
|
||||||
|
fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt)))
|
||||||
|
fmt.Printf(" Heartbeat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat)))
|
||||||
|
fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks)
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
|
// svcExec runs a service management command with output flowing to the terminal.
|
||||||
|
func svcExec(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// svcExecInteractive is like svcExec but also connects stdin (needed for follow/pager modes).
|
||||||
|
func svcExecInteractive(name string, args ...string) error {
|
||||||
|
cmd := exec.Command(name, args...)
|
||||||
|
cmd.Stdin = os.Stdin
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,14 @@ import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemdTemplate = `[Unit]
|
const systemdTemplate = `[Unit]
|
||||||
|
|
@ -123,6 +127,8 @@ func runDaemonInstall() error {
|
||||||
return installSystemd(data, green)
|
return installSystemd(data, green)
|
||||||
case "darwin":
|
case "darwin":
|
||||||
return installLaunchd(data, green)
|
return installLaunchd(data, green)
|
||||||
|
case "windows":
|
||||||
|
return installWindowsTask(data, green)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
|
return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
@ -228,6 +234,17 @@ func runDaemonUninstall() error {
|
||||||
os.Remove(path)
|
os.Remove(path)
|
||||||
green.Printf(" ✓ Removed %s\n", path)
|
green.Printf(" ✓ Removed %s\n", path)
|
||||||
|
|
||||||
|
case "windows":
|
||||||
|
// Stop the running process if any
|
||||||
|
if state := agent.ReadState(); state != nil {
|
||||||
|
exec.Command("taskkill", "/pid", strconv.Itoa(state.PID), "/f").Run()
|
||||||
|
}
|
||||||
|
out, err := exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").CombinedOutput()
|
||||||
|
if err != nil && !strings.Contains(string(out), "cannot find") {
|
||||||
|
return fmt.Errorf("remove scheduled task: %w\n%s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
green.Println(" ✓ Scheduled task removed")
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
|
return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS)
|
||||||
}
|
}
|
||||||
|
|
@ -235,3 +252,45 @@ func runDaemonUninstall() error {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installWindowsTask(data serviceData, green *color.Color) error {
|
||||||
|
logDir := config.DataDir()
|
||||||
|
os.MkdirAll(logDir, 0o755)
|
||||||
|
|
||||||
|
// Remove any existing task before (re)installing.
|
||||||
|
exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").Run()
|
||||||
|
|
||||||
|
// Wrap with PowerShell so stdout/stderr are captured to a log file.
|
||||||
|
psScript := fmt.Sprintf(
|
||||||
|
`Start-Transcript -Path '%s\unarr.log' -Append -NoClobber; & '%s' start`,
|
||||||
|
logDir, data.BinPath,
|
||||||
|
)
|
||||||
|
taskCmd := fmt.Sprintf(`powershell.exe -NonInteractive -WindowStyle Hidden -Command "%s"`, psScript)
|
||||||
|
|
||||||
|
out, err := exec.Command("schtasks",
|
||||||
|
"/create",
|
||||||
|
"/tn", "unarr",
|
||||||
|
"/tr", taskCmd,
|
||||||
|
"/sc", "onlogon",
|
||||||
|
"/ru", data.User,
|
||||||
|
"/rl", "highest",
|
||||||
|
"/f",
|
||||||
|
).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create scheduled task: %w\n%s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
green.Println(" ✓ Installed! Service will start automatically at next login.")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" To start now:")
|
||||||
|
fmt.Println(" unarr daemon start")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" Manage with:")
|
||||||
|
fmt.Println(" unarr daemon status")
|
||||||
|
fmt.Println(" unarr daemon stop")
|
||||||
|
fmt.Printf(" unarr daemon logs (log: %s\\unarr.log)\n", logDir)
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,17 +113,18 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
|
||||||
MetadataTimeout: 15 * time.Minute,
|
MetadataTimeout: 15 * time.Minute,
|
||||||
StallTimeout: 10 * time.Minute,
|
StallTimeout: 10 * time.Minute,
|
||||||
MaxTimeout: 0, // unlimited
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("create downloader: %w", err)
|
return fmt.Errorf("create downloader: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a dummy reporter (no API reporting for one-shot)
|
// Local-only reporter: one-shot downloads have no server-side task, so a nil
|
||||||
reporter := engine.NewProgressReporter(
|
// client keeps terminal progress working without spamming the status API
|
||||||
deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
|
// (which 400s the synthetic "oneshot-" id).
|
||||||
5*time.Second,
|
reporter := engine.NewProgressReporter(nil, 5*time.Second)
|
||||||
)
|
|
||||||
|
|
||||||
debridDl := deps.newDebridDl()
|
debridDl := deps.newDebridDl()
|
||||||
|
|
||||||
|
|
|
||||||
165
internal/cmd/funnel.go
Normal file
165
internal/cmd/funnel.go
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newFunnelCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "funnel",
|
||||||
|
Short: "Expose the daemon over a public HTTPS hostname via CloudFlare Quick Tunnel",
|
||||||
|
Long: `Turn the CloudFlare Quick Tunnel on/off and check its status.
|
||||||
|
|
||||||
|
When on, the daemon spawns cloudflared as a child process and registers a
|
||||||
|
` + "`https://<random>.trycloudflare.com`" + ` hostname tunnelled to its local
|
||||||
|
HLS server. The torrentclaw.com / torrentclaw.to web player picks the tunnel
|
||||||
|
URL first so cross-network playback works from any browser without Tailscale
|
||||||
|
or port forwarding.
|
||||||
|
|
||||||
|
Trade-offs:
|
||||||
|
• Bytes proxy through CloudFlare. We don't relay; CF does. Preserves the
|
||||||
|
TorrentClaw legal posture but means CF sees your traffic shape.
|
||||||
|
• Quick Tunnels are anonymous — no CF account required.
|
||||||
|
• Hostname is random per session and rotates roughly every 6 h.
|
||||||
|
|
||||||
|
Requires the cloudflared binary on PATH. Install:
|
||||||
|
Linux : https://pkg.cloudflare.com (apt) or download from
|
||||||
|
https://github.com/cloudflare/cloudflared/releases
|
||||||
|
macOS : brew install cloudflared
|
||||||
|
Windows: winget install --id Cloudflare.cloudflared`,
|
||||||
|
Example: ` unarr funnel status # is the tunnel up? what's the URL?
|
||||||
|
unarr funnel on # turn it on
|
||||||
|
unarr funnel off # turn it off`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newFunnelStatusCmd(), newFunnelOnCmd(), newFunnelOffCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFunnelStatusCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show CloudFlare tunnel configuration + live URL",
|
||||||
|
Example: " unarr funnel status",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runFunnelStatus()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runFunnelStatus() error {
|
||||||
|
bold := color.New(color.Bold)
|
||||||
|
dim := color.New(color.FgHiBlack)
|
||||||
|
green := color.New(color.FgGreen)
|
||||||
|
yellow := color.New(color.FgYellow)
|
||||||
|
cyan := color.New(color.FgCyan)
|
||||||
|
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
bold.Println(" CloudFlare Quick Tunnel")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
if !cfg.Download.Funnel.Enabled {
|
||||||
|
dim.Println(" Mode: off")
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" Enable with `unarr funnel on` to give the daemon a public HTTPS URL")
|
||||||
|
dim.Println(" so cross-network browser playback works without Tailscale.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cyan.Println(" Mode: on")
|
||||||
|
|
||||||
|
state := agent.ReadState()
|
||||||
|
alive := state != nil && isDaemonAlive(state)
|
||||||
|
fmt.Println()
|
||||||
|
switch {
|
||||||
|
case alive && state.FunnelURL != "":
|
||||||
|
green.Println(" ✓ Tunnel ACTIVE")
|
||||||
|
fmt.Printf(" URL: %s\n", state.FunnelURL)
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" This URL rotates roughly every 6 h. The web player picks it up")
|
||||||
|
dim.Println(" automatically — no action needed on your side.")
|
||||||
|
case alive:
|
||||||
|
yellow.Println(" ⚠ Daemon is running but the tunnel hasn't registered yet.")
|
||||||
|
dim.Println(" Check `unarr daemon logs` for a [funnel] line. Common cause:")
|
||||||
|
dim.Println(" cloudflared isn't installed on PATH.")
|
||||||
|
default:
|
||||||
|
dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFunnelOnCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "on",
|
||||||
|
Short: "Turn the CloudFlare tunnel on",
|
||||||
|
Example: " unarr funnel on",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return setFunnelEnabled(true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFunnelOffCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "off",
|
||||||
|
Short: "Turn the CloudFlare tunnel off",
|
||||||
|
Example: " unarr funnel off",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return setFunnelEnabled(false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFunnelEnabled(enabled bool) error {
|
||||||
|
green := color.New(color.FgGreen)
|
||||||
|
dim := color.New(color.FgHiBlack)
|
||||||
|
|
||||||
|
cfg := loadConfig()
|
||||||
|
if cfg.Download.Funnel.Enabled == enabled {
|
||||||
|
fmt.Println()
|
||||||
|
dim.Printf(" Tunnel is already %s — nothing to do.\n", onOffWord(enabled))
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Download.Funnel.Enabled = enabled
|
||||||
|
|
||||||
|
configPath := config.FilePath()
|
||||||
|
if cfgFile != "" {
|
||||||
|
configPath = cfgFile
|
||||||
|
}
|
||||||
|
if err := config.Save(cfg, configPath); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
appCfg = cfg
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
green.Printf(" ✓ CloudFlare tunnel %s.\n", onOffWord(enabled))
|
||||||
|
|
||||||
|
// Subprocess is launched/torn down by the daemon at startup; a plain config
|
||||||
|
// reload does not bring it up. Prompt for a restart when the daemon is alive.
|
||||||
|
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" The daemon is running. Restart it for this to take effect:")
|
||||||
|
dim.Println(" unarr daemon restart")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func onOffWord(enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return "on"
|
||||||
|
}
|
||||||
|
return "off"
|
||||||
|
}
|
||||||
|
|
@ -9,12 +9,21 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// openBrowser opens a URL in the default browser.
|
// openBrowser opens a URL in the default browser.
|
||||||
|
//
|
||||||
|
// The URL is restricted to http(s) so that a hostile caller cannot trick
|
||||||
|
// xdg-open/open into interpreting it as a flag (a leading "-" would otherwise
|
||||||
|
// match a switch on every helper we shell out to). Where the helper supports
|
||||||
|
// it we also append "--" to terminate switch parsing as belt-and-braces.
|
||||||
func openBrowser(url string) {
|
func openBrowser(url string) {
|
||||||
|
if !isSafeBrowserURL(url) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var c *exec.Cmd
|
var c *exec.Cmd
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "darwin":
|
case "darwin":
|
||||||
c = exec.Command("open", url)
|
c = exec.Command("open", "--", url)
|
||||||
case "windows":
|
case "windows":
|
||||||
|
// rundll32 does not parse switches from positional args.
|
||||||
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
|
||||||
default: // linux, freebsd
|
default: // linux, freebsd
|
||||||
c = exec.Command("xdg-open", url)
|
c = exec.Command("xdg-open", url)
|
||||||
|
|
@ -22,6 +31,12 @@ func openBrowser(url string) {
|
||||||
_ = c.Start() // fire and forget; best-effort
|
_ = c.Start() // fire and forget; best-effort
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSafeBrowserURL accepts only http(s) URLs. Other schemes (file://, javascript:,
|
||||||
|
// data:, ...) and flag-shaped strings ("--help") are rejected.
|
||||||
|
func isSafeBrowserURL(url string) bool {
|
||||||
|
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
// defaultDownloadDir returns a sensible default download directory.
|
// defaultDownloadDir returns a sensible default download directory.
|
||||||
func defaultDownloadDir() string {
|
func defaultDownloadDir() string {
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,32 @@ func TestExpandHome(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIsSafeBrowserURL(t *testing.T) {
|
||||||
|
good := []string{
|
||||||
|
"http://localhost:3000",
|
||||||
|
"https://torrentclaw.com/some/path?q=1",
|
||||||
|
}
|
||||||
|
bad := []string{
|
||||||
|
"--help",
|
||||||
|
"-version",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"javascript:alert(1)",
|
||||||
|
"data:text/html,foo",
|
||||||
|
"ftp://example.com",
|
||||||
|
"",
|
||||||
|
}
|
||||||
|
for _, u := range good {
|
||||||
|
if !isSafeBrowserURL(u) {
|
||||||
|
t.Errorf("isSafeBrowserURL(%q) = false, want true", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, u := range bad {
|
||||||
|
if isSafeBrowserURL(u) {
|
||||||
|
t.Errorf("isSafeBrowserURL(%q) = true, want false", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultDownloadDir(t *testing.T) {
|
func TestDefaultDownloadDir(t *testing.T) {
|
||||||
dir := defaultDownloadDir()
|
dir := defaultDownloadDir()
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
|
|
|
||||||
204
internal/cmd/mirrors.go
Normal file
204
internal/cmd/mirrors.go
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMirrorsCmd wires `unarr mirrors` and its subcommands.
|
||||||
|
//
|
||||||
|
// Mirrors are alternate base URLs the agent can fall back to when the
|
||||||
|
// primary api_url is unreachable. The pool is consulted on every transient
|
||||||
|
// network failure (DNS, refused, timeout, 5xx) — see internal/agent/
|
||||||
|
// mirror_pool.go for the rotation rules.
|
||||||
|
func newMirrorsCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "mirrors",
|
||||||
|
Short: "Manage TorrentClaw mirror failover list",
|
||||||
|
Long: `Mirrors are alternate base URLs the agent falls back to when the primary
|
||||||
|
domain is unreachable. The pool survives DNS blocks, ISP filters, and
|
||||||
|
short-lived takedowns without restarting the agent.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
unarr mirrors list Print currently configured mirrors
|
||||||
|
unarr mirrors update Refresh from the server's canonical list
|
||||||
|
unarr mirrors test Probe every configured mirror`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(newMirrorsListCmd())
|
||||||
|
cmd.AddCommand(newMirrorsUpdateCmd())
|
||||||
|
cmd.AddCommand(newMirrorsTestCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMirrorsListCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "Print currently configured mirrors",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
pool := agent.NewMirrorPool(cfg.Auth.APIURL, cfg.Auth.Mirrors)
|
||||||
|
|
||||||
|
if jsonOut {
|
||||||
|
out := map[string]any{
|
||||||
|
"primary": cfg.Auth.APIURL,
|
||||||
|
"mirrors": pool.Mirrors(),
|
||||||
|
}
|
||||||
|
return json.NewEncoder(os.Stdout).Encode(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Primary: %s\n", color.GreenString(cfg.Auth.APIURL))
|
||||||
|
if len(cfg.Auth.Mirrors) == 0 {
|
||||||
|
fmt.Println("Fallbacks: (none configured — run `unarr mirrors update`)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Println("Fallbacks:")
|
||||||
|
for i, m := range cfg.Auth.Mirrors {
|
||||||
|
fmt.Printf(" %d. %s\n", i+1, m)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMirrorsUpdateCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "update",
|
||||||
|
Short: "Refresh the mirror list from the server",
|
||||||
|
Long: `Fetch /api/v1/mirrors from the configured primary (with fallback to any
|
||||||
|
currently-known mirrors) and write the resulting list back to config.toml.
|
||||||
|
|
||||||
|
This is how long-running agents survive a takedown of the primary domain:
|
||||||
|
the user runs ` + "`unarr mirrors update`" + ` once a week (or via cron), and
|
||||||
|
the agent transparently picks up new mirrors without a CLI release.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
// Candidate set: primary + any currently-known mirrors. Order matters —
|
||||||
|
// we try primary first so the most-trusted endpoint wins.
|
||||||
|
candidates := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
fmt.Println("Refreshing mirror list...")
|
||||||
|
resp, err := agent.FetchMirrorsWithFallback(ctx, candidates, "unarr/"+Version)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch mirrors: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, extras := resp.ToConfig()
|
||||||
|
if primary == "" {
|
||||||
|
return fmt.Errorf("server returned no mirrors")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track what changed so we can give the user a clear diff.
|
||||||
|
added, removed := diffMirrors(append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...), append([]string{primary}, extras...))
|
||||||
|
|
||||||
|
cfg.Auth.APIURL = primary
|
||||||
|
cfg.Auth.Mirrors = extras
|
||||||
|
if err := config.Save(cfg, cfgFile); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s revision %d (%d mirror%s)\n",
|
||||||
|
color.GreenString("✓"), resp.Revision, len(resp.Mirrors), pluralS(len(resp.Mirrors)))
|
||||||
|
fmt.Printf(" Primary: %s\n", primary)
|
||||||
|
if len(extras) > 0 {
|
||||||
|
fmt.Printf(" Fallbacks: %s\n", strings.Join(extras, ", "))
|
||||||
|
}
|
||||||
|
if resp.Tor != nil {
|
||||||
|
fmt.Printf(" Tor: %s\n", resp.Tor.URL)
|
||||||
|
}
|
||||||
|
for _, c := range resp.Channels {
|
||||||
|
fmt.Printf(" Channel: %s — %s\n", c.Label, c.URL)
|
||||||
|
}
|
||||||
|
if len(added) > 0 {
|
||||||
|
fmt.Printf(" %s %s\n", color.GreenString("added:"), strings.Join(added, ", "))
|
||||||
|
}
|
||||||
|
if len(removed) > 0 {
|
||||||
|
fmt.Printf(" %s %s\n", color.YellowString("removed:"), strings.Join(removed, ", "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMirrorsTestCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "test",
|
||||||
|
Short: "Probe every configured mirror",
|
||||||
|
Long: `Performs a small unauthenticated HEAD/GET against /api/health on every
|
||||||
|
configured mirror and reports latency + reachability.`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
cfg := loadConfig()
|
||||||
|
all := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
|
||||||
|
if len(all) == 0 {
|
||||||
|
return fmt.Errorf("no mirrors configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, base := range all {
|
||||||
|
if base == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
|
||||||
|
start := time.Now()
|
||||||
|
_, err := agent.FetchMirrors(ctx, []string{base}, "unarr/"+Version)
|
||||||
|
cancel()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" %s %s — %s (%s)\n", color.RedString("✗"), base, err, elapsed.Round(time.Millisecond))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf(" %s %s (%s)\n", color.GreenString("✓"), base, elapsed.Round(time.Millisecond))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffMirrors returns the URLs added and removed between two ordered lists.
|
||||||
|
// Used to print a friendly diff after `unarr mirrors update`.
|
||||||
|
func diffMirrors(old, fresh []string) (added, removed []string) {
|
||||||
|
oldSet := make(map[string]struct{}, len(old))
|
||||||
|
for _, m := range old {
|
||||||
|
if m != "" {
|
||||||
|
oldSet[m] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
freshSet := make(map[string]struct{}, len(fresh))
|
||||||
|
for _, m := range fresh {
|
||||||
|
if m == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
freshSet[m] = struct{}{}
|
||||||
|
if _, ok := oldSet[m]; !ok {
|
||||||
|
added = append(added, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range old {
|
||||||
|
if m == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := freshSet[m]; !ok {
|
||||||
|
removed = append(removed, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return added, removed
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluralS(n int) string {
|
||||||
|
if n == 1 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "s"
|
||||||
|
}
|
||||||
102
internal/cmd/player_session_registry.go
Normal file
102
internal/cmd/player_session_registry.go
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
|
"github.com/torrentclaw/unarr/internal/engine"
|
||||||
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// playerSessionRegistry tracks per-session cancel funcs for active in-browser
|
||||||
|
// HLS streaming sessions. Each session lives only as long as its ffmpeg
|
||||||
|
// process; the registry exists so duplicate sync responses don't double-spawn
|
||||||
|
// the same session and so daemon shutdown can drain.
|
||||||
|
var playerSessionRegistry = &playerSessionRegistryT{
|
||||||
|
cancels: make(map[string]context.CancelFunc),
|
||||||
|
}
|
||||||
|
|
||||||
|
type playerSessionRegistryT struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cancels map[string]context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *playerSessionRegistryT) has(sessionID string) bool {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
_, ok := r.cancels[sessionID]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *playerSessionRegistryT) add(sessionID string, cancel context.CancelFunc) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.cancels[sessionID] = cancel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *playerSessionRegistryT) remove(sessionID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.cancels, sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cancelAllPlayerSessions cancels every running session. Called on daemon
|
||||||
|
// shutdown so the ffmpeg children and SSE consumers exit cleanly.
|
||||||
|
func cancelAllPlayerSessions() {
|
||||||
|
playerSessionRegistry.mu.Lock()
|
||||||
|
cancels := make([]context.CancelFunc, 0, len(playerSessionRegistry.cancels))
|
||||||
|
for _, c := range playerSessionRegistry.cancels {
|
||||||
|
cancels = append(cancels, c)
|
||||||
|
}
|
||||||
|
playerSessionRegistry.cancels = make(map[string]context.CancelFunc)
|
||||||
|
playerSessionRegistry.mu.Unlock()
|
||||||
|
for _, c := range cancels {
|
||||||
|
c()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
|
||||||
|
// for the HLS streaming pipeline. Failure to resolve a binary returns a
|
||||||
|
// runtime with empty paths so the caller can short-circuit instead of
|
||||||
|
// launching a transcoder that will immediately fail.
|
||||||
|
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
|
||||||
|
if !cfg.Download.Transcode.Enabled {
|
||||||
|
return engine.TranscodeRuntime{Disabled: true}
|
||||||
|
}
|
||||||
|
ffmpegPath, errF := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
|
||||||
|
ffprobePath, errP := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath)
|
||||||
|
if errF != nil || errP != nil {
|
||||||
|
return engine.TranscodeRuntime{Disabled: true}
|
||||||
|
}
|
||||||
|
hw := engine.HWAccelNone
|
||||||
|
switch cfg.Download.Transcode.HWAccel {
|
||||||
|
case "auto":
|
||||||
|
hw = engine.DetectHWAccel(ctx, ffmpegPath)
|
||||||
|
case "nvenc":
|
||||||
|
hw = engine.HWAccelNVENC
|
||||||
|
case "qsv":
|
||||||
|
hw = engine.HWAccelQSV
|
||||||
|
case "vaapi":
|
||||||
|
hw = engine.HWAccelVAAPI
|
||||||
|
case "videotoolbox":
|
||||||
|
hw = engine.HWAccelVideoToolbox
|
||||||
|
case "none", "":
|
||||||
|
hw = engine.HWAccelNone
|
||||||
|
}
|
||||||
|
return engine.TranscodeRuntime{
|
||||||
|
FFmpegPath: ffmpegPath,
|
||||||
|
FFprobePath: ffprobePath,
|
||||||
|
HWAccel: hw,
|
||||||
|
Preset: cfg.Download.Transcode.Preset,
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
176
internal/cmd/probe_hwaccel.go
Normal file
176
internal/cmd/probe_hwaccel.go
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newProbeHWAccelCmd reports the hardware-acceleration capabilities the daemon
|
||||||
|
// would actually use for HLS transcoding. The motivation: a beefy host
|
||||||
|
// (e.g. RTX 3090) can still fall back to software encoding when the installed
|
||||||
|
// ffmpeg binary was built without nvenc/qsv/vaapi support — Homebrew ffmpeg
|
||||||
|
// is a common offender. Without this command, users see slow / failing 4K
|
||||||
|
// transcodes and no obvious way to diagnose where the regression sits.
|
||||||
|
func newProbeHWAccelCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "probe-hwaccel",
|
||||||
|
Short: "Diagnose hardware-acceleration availability",
|
||||||
|
Long: `Report the hardware-acceleration backends the daemon would pick for
|
||||||
|
transcoding, plus exactly why each one was kept or rejected.
|
||||||
|
|
||||||
|
Checks performed:
|
||||||
|
- ffmpeg / ffprobe paths
|
||||||
|
- which HW encoders the ffmpeg binary supports (h264_nvenc, h264_qsv, h264_vaapi…)
|
||||||
|
- whether the matching device files / drivers are actually present
|
||||||
|
- which backend the daemon would pick today (HWAccelNone means software)
|
||||||
|
|
||||||
|
Use this when transcoding feels slow or fails on 4K — the most common cause
|
||||||
|
is a software-only ffmpeg build, not a missing GPU.`,
|
||||||
|
Example: ` unarr probe-hwaccel`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runProbeHWAccel()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runProbeHWAccel() error {
|
||||||
|
bold := color.New(color.Bold)
|
||||||
|
green := color.New(color.FgGreen)
|
||||||
|
yellow := color.New(color.FgYellow)
|
||||||
|
red := color.New(color.FgRed)
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
bold.Println(" Hardware acceleration probe")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 1. Locate ffmpeg / ffprobe.
|
||||||
|
ffmpegPath, ffmpegErr := exec.LookPath("ffmpeg")
|
||||||
|
ffprobePath, ffprobeErr := exec.LookPath("ffprobe")
|
||||||
|
|
||||||
|
bold.Println(" Binaries")
|
||||||
|
if ffmpegErr != nil {
|
||||||
|
red.Printf(" x ffmpeg not on PATH\n")
|
||||||
|
fmt.Println()
|
||||||
|
yellow.Println(" HW probe needs ffmpeg. Install it:")
|
||||||
|
fmt.Println(" Ubuntu/Debian: sudo apt install ffmpeg")
|
||||||
|
fmt.Println(" macOS: brew install ffmpeg")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
green.Printf(" OK ffmpeg %s\n", ffmpegPath)
|
||||||
|
if ffprobeErr != nil {
|
||||||
|
yellow.Printf(" ! ffprobe not on PATH (HLS still works, source probing falls back to ffmpeg)\n")
|
||||||
|
} else {
|
||||||
|
green.Printf(" OK ffprobe %s\n", ffprobePath)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 2. List encoders the ffmpeg binary supports.
|
||||||
|
bold.Println(" HW encoders compiled in")
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
out, err := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
red.Printf(" x ffmpeg -encoders failed: %v\n", err)
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
encoders := string(out)
|
||||||
|
|
||||||
|
hwEncoders := []struct {
|
||||||
|
name string
|
||||||
|
family string
|
||||||
|
family2 string
|
||||||
|
}{
|
||||||
|
{"h264_nvenc", "NVIDIA NVENC", "hevc_nvenc"},
|
||||||
|
{"h264_qsv", "Intel Quick Sync", "hevc_qsv"},
|
||||||
|
{"h264_vaapi", "Linux VA-API (Intel/AMD)", "hevc_vaapi"},
|
||||||
|
{"h264_videotoolbox", "macOS VideoToolbox", "hevc_videotoolbox"},
|
||||||
|
}
|
||||||
|
anyHWEncoder := false
|
||||||
|
for _, e := range hwEncoders {
|
||||||
|
hasH264 := strings.Contains(encoders, e.name)
|
||||||
|
hasHEVC := strings.Contains(encoders, e.family2)
|
||||||
|
if hasH264 || hasHEVC {
|
||||||
|
anyHWEncoder = true
|
||||||
|
green.Printf(" OK %s\n", e.family)
|
||||||
|
if hasH264 {
|
||||||
|
fmt.Printf(" %s\n", e.name)
|
||||||
|
}
|
||||||
|
if hasHEVC {
|
||||||
|
fmt.Printf(" %s\n", e.family2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !anyHWEncoder {
|
||||||
|
red.Printf(" x No HW encoders compiled in\n")
|
||||||
|
fmt.Println()
|
||||||
|
yellow.Println(" Most likely your ffmpeg was built without --enable-nvenc /")
|
||||||
|
yellow.Println(" --enable-libmfx / --enable-vaapi. Brew's default formula is one")
|
||||||
|
yellow.Println(" common offender. On Ubuntu, the system package ships with VAAPI")
|
||||||
|
yellow.Println(" by default and NVENC if you have CUDA installed.")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 3. Device-file checks.
|
||||||
|
bold.Println(" Devices / drivers")
|
||||||
|
checks := []struct {
|
||||||
|
path string
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
{"/dev/nvidia0", "NVIDIA GPU"},
|
||||||
|
{"/dev/dri/renderD128", "Linux DRM render node (used by VA-API + QSV)"},
|
||||||
|
}
|
||||||
|
for _, c := range checks {
|
||||||
|
if fileExistsLocal(c.path) {
|
||||||
|
green.Printf(" OK %s — %s\n", c.path, c.desc)
|
||||||
|
} else {
|
||||||
|
yellow.Printf(" - %s — %s (not present)\n", c.path, c.desc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("nvidia-smi"); err == nil {
|
||||||
|
green.Printf(" OK nvidia-smi on PATH\n")
|
||||||
|
} else {
|
||||||
|
yellow.Printf(" - nvidia-smi not on PATH\n")
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "darwin" {
|
||||||
|
fmt.Printf(" . macOS host — VideoToolbox available if encoder was compiled in\n")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// 4. Daemon's actual decision.
|
||||||
|
engine.ResetHWAccelCache()
|
||||||
|
pick := engine.DetectHWAccel(ctx, ffmpegPath)
|
||||||
|
bold.Println(" Daemon would pick")
|
||||||
|
switch pick {
|
||||||
|
case engine.HWAccelNone:
|
||||||
|
red.Printf(" x %s — software libx264 only\n", pick)
|
||||||
|
fmt.Println()
|
||||||
|
yellow.Println(" On a slow CPU 1080p will lag and 4K is effectively unwatchable.")
|
||||||
|
yellow.Println(" Fix: rebuild / reinstall ffmpeg with HW encoder support, then:")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" unarr daemon restart")
|
||||||
|
default:
|
||||||
|
green.Printf(" OK %s\n", pick)
|
||||||
|
fmt.Printf(" encoder: %s (h264) / %s (hevc)\n", pick.FFmpegVideoCodec("h264"), pick.FFmpegVideoCodec("hevc"))
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileExistsLocal stats a path. Mirrors engine.fileExists without exporting it.
|
||||||
|
func fileExistsLocal(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
@ -3,11 +3,14 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
"github.com/torrentclaw/unarr/internal/agent"
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
)
|
)
|
||||||
|
|
@ -38,3 +41,40 @@ 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)
|
||||||
|
}
|
||||||
|
p, err := os.FindProcess(state.PID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find process %d: %w", state.PID, err)
|
||||||
|
}
|
||||||
|
if err := p.Signal(syscall.SIGUSR1); err != nil {
|
||||||
|
return fmt.Errorf("send reload signal to PID %d: %w", state.PID, err)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
color.New(color.FgGreen).Printf(" ✓ Reload signal sent to daemon (PID %d)\n", state.PID)
|
||||||
|
fmt.Println(" Config will be re-read shortly.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// killPID sends SIGTERM to the given PID for a graceful shutdown.
|
||||||
|
func killPID(pid int) error {
|
||||||
|
p, err := os.FindProcess(pid)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("find process %d: %w", pid, err)
|
||||||
|
}
|
||||||
|
if err := p.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
return fmt.Errorf("stop daemon (PID %d): %w", pid, err)
|
||||||
|
}
|
||||||
|
color.New(color.FgGreen).Printf(" ✓ Stop signal sent to daemon (PID %d)\n", pid)
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,15 @@
|
||||||
|
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
import "github.com/torrentclaw/unarr/internal/agent"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/fatih/color"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
|
)
|
||||||
|
|
||||||
// ReloadableConfig holds a reference to the daemon for hot-reload.
|
// ReloadableConfig holds a reference to the daemon for hot-reload.
|
||||||
type ReloadableConfig struct {
|
type ReloadableConfig struct {
|
||||||
|
|
@ -11,3 +19,25 @@ type ReloadableConfig struct {
|
||||||
|
|
||||||
// startReloadWatcher is a no-op on Windows (no SIGUSR1 support).
|
// startReloadWatcher is a no-op on Windows (no SIGUSR1 support).
|
||||||
func startReloadWatcher(_ *ReloadableConfig) {}
|
func startReloadWatcher(_ *ReloadableConfig) {}
|
||||||
|
|
||||||
|
// sendReloadSignal is not supported on Windows; instructs the user to restart instead.
|
||||||
|
func sendReloadSignal() error {
|
||||||
|
fmt.Println()
|
||||||
|
color.New(color.FgYellow).Println(" ⚠ Config reload via signal is not supported on Windows.")
|
||||||
|
fmt.Println(" Use 'unarr daemon restart' to apply configuration changes.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// killPID stops the daemon process on Windows using taskkill.
|
||||||
|
func killPID(pid int) error {
|
||||||
|
cmd := exec.Command("taskkill", "/pid", strconv.Itoa(pid), "/f")
|
||||||
|
cmd.Stdout = os.Stdout
|
||||||
|
cmd.Stderr = os.Stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("stop daemon (PID %d): %w", pid, err)
|
||||||
|
}
|
||||||
|
color.New(color.FgGreen).Printf(" ✓ Daemon stopped (PID %d)\n", pid)
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
74
internal/cmd/relocate_test.go
Normal file
74
internal/cmd/relocate_test.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,13 +2,17 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
tc "github.com/torrentclaw/go-client"
|
tc "github.com/torrentclaw/go-client"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
"github.com/torrentclaw/unarr/internal/sentry"
|
"github.com/torrentclaw/unarr/internal/sentry"
|
||||||
|
"github.com/torrentclaw/unarr/internal/upgrade"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -25,15 +29,19 @@ var (
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "unarr",
|
Use: "unarr",
|
||||||
Short: "unarr — torrent search and management",
|
Version: Version,
|
||||||
Long: `unarr is a powerful terminal tool for torrent search and management.
|
Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
|
||||||
|
Long: `unarr is a terminal-native client that downloads torrents, debrid links,
|
||||||
Search 30+ torrent sources, inspect torrent quality, discover popular content,
|
and usenet (NZB) — all from the same binary. It streams content straight
|
||||||
find streaming providers, and manage your media collection — all from your terminal.
|
to mpv/vlc with sequential piece prioritization, transcodes on the fly via
|
||||||
|
ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
|
||||||
|
organizes your library into Movies/TV folders. Run it one-shot or as a
|
||||||
|
long-running daemon with a built-in WireGuard split-tunnel and remote
|
||||||
|
playback over Cloudflare Funnel.
|
||||||
|
|
||||||
Get started:
|
Get started:
|
||||||
unarr init First-time configuration wizard
|
unarr init First-time configuration wizard
|
||||||
unarr search "breaking bad" Search for content
|
unarr download <magnet|hash> Grab a torrent one-shot
|
||||||
unarr start Start the download daemon
|
unarr start Start the download daemon
|
||||||
|
|
||||||
Documentation: https://torrentclaw.com/cli
|
Documentation: https://torrentclaw.com/cli
|
||||||
|
|
@ -42,6 +50,10 @@ Source: https://github.com/torrentclaw/unarr`,
|
||||||
if noColor || os.Getenv("NO_COLOR") != "" {
|
if noColor || os.Getenv("NO_COLOR") != "" {
|
||||||
color.NoColor = true
|
color.NoColor = true
|
||||||
}
|
}
|
||||||
|
// Self-updater fetches releases from the configured host (default
|
||||||
|
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
|
||||||
|
// UNARR_API_URL all route updates correctly.
|
||||||
|
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
|
||||||
},
|
},
|
||||||
SilenceUsage: true,
|
SilenceUsage: true,
|
||||||
SilenceErrors: true,
|
SilenceErrors: true,
|
||||||
|
|
@ -50,7 +62,7 @@ Source: https://github.com/torrentclaw/unarr`,
|
||||||
// Command groups for organized help output
|
// Command groups for organized help output
|
||||||
rootCmd.AddGroup(
|
rootCmd.AddGroup(
|
||||||
&cobra.Group{ID: "start", Title: "Getting Started:"},
|
&cobra.Group{ID: "start", Title: "Getting Started:"},
|
||||||
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
|
&cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
|
||||||
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
|
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
|
||||||
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
|
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
|
||||||
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
|
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
|
||||||
|
|
@ -98,14 +110,22 @@ Source: https://github.com/torrentclaw/unarr`,
|
||||||
statusCmd.GroupID = "daemon"
|
statusCmd.GroupID = "daemon"
|
||||||
daemonCmd := newDaemonCmd()
|
daemonCmd := newDaemonCmd()
|
||||||
daemonCmd.GroupID = "daemon"
|
daemonCmd.GroupID = "daemon"
|
||||||
|
vpnCmd := newVPNCmd()
|
||||||
|
vpnCmd.GroupID = "daemon"
|
||||||
|
funnelCmd := newFunnelCmd()
|
||||||
|
funnelCmd.GroupID = "daemon"
|
||||||
|
|
||||||
// System & Diagnostics
|
// System & Diagnostics
|
||||||
statsCmd := newStatsCmd()
|
statsCmd := newStatsCmd()
|
||||||
statsCmd.GroupID = "system"
|
statsCmd.GroupID = "system"
|
||||||
doctorCmd := newDoctorCmd()
|
doctorCmd := newDoctorCmd()
|
||||||
doctorCmd.GroupID = "system"
|
doctorCmd.GroupID = "system"
|
||||||
|
probeHWAccelCmd := newProbeHWAccelCmd()
|
||||||
|
probeHWAccelCmd.GroupID = "system"
|
||||||
cleanCmd := newCleanCmd()
|
cleanCmd := newCleanCmd()
|
||||||
cleanCmd.GroupID = "system"
|
cleanCmd.GroupID = "system"
|
||||||
|
mirrorsCmd := newMirrorsCmd()
|
||||||
|
mirrorsCmd.GroupID = "system"
|
||||||
selfUpdateCmd := newSelfUpdateCmd()
|
selfUpdateCmd := newSelfUpdateCmd()
|
||||||
selfUpdateCmd.GroupID = "system"
|
selfUpdateCmd.GroupID = "system"
|
||||||
versionCmd := newVersionCmd()
|
versionCmd := newVersionCmd()
|
||||||
|
|
@ -137,10 +157,14 @@ Source: https://github.com/torrentclaw/unarr`,
|
||||||
stopCmd,
|
stopCmd,
|
||||||
statusCmd,
|
statusCmd,
|
||||||
daemonCmd,
|
daemonCmd,
|
||||||
|
vpnCmd,
|
||||||
|
funnelCmd,
|
||||||
// System & Diagnostics
|
// System & Diagnostics
|
||||||
statsCmd,
|
statsCmd,
|
||||||
doctorCmd,
|
doctorCmd,
|
||||||
|
probeHWAccelCmd,
|
||||||
cleanCmd,
|
cleanCmd,
|
||||||
|
mirrorsCmd,
|
||||||
selfUpdateCmd,
|
selfUpdateCmd,
|
||||||
versionCmd,
|
versionCmd,
|
||||||
completionCmd,
|
completionCmd,
|
||||||
|
|
@ -214,6 +238,21 @@ func getClient() *tc.Client {
|
||||||
|
|
||||||
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
|
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...)
|
apiClient = tc.NewClient(opts...)
|
||||||
return apiClient
|
return apiClient
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import (
|
||||||
"github.com/torrentclaw/unarr/internal/agent"
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
"github.com/torrentclaw/unarr/internal/config"
|
"github.com/torrentclaw/unarr/internal/config"
|
||||||
"github.com/torrentclaw/unarr/internal/library"
|
"github.com/torrentclaw/unarr/internal/library"
|
||||||
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newScanCmd() *cobra.Command {
|
func newScanCmd() *cobra.Command {
|
||||||
|
|
@ -41,11 +42,16 @@ to see available quality upgrades.`,
|
||||||
}
|
}
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
cfg := loadConfig()
|
cfg := loadConfig()
|
||||||
if cfg.Library.ScanPath != "" {
|
paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
|
||||||
args = append(args, cfg.Library.ScanPath)
|
if len(paths) == 0 {
|
||||||
} else {
|
return fmt.Errorf("usage: unarr scan <path>\n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'")
|
||||||
return fmt.Errorf("usage: unarr scan <path>\n\nProvide a media folder to scan")
|
|
||||||
}
|
}
|
||||||
|
for _, p := range paths {
|
||||||
|
if err := runScan(p, workers, ffprobe, noSync); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
return runScan(args[0], workers, ffprobe, noSync)
|
return runScan(args[0], workers, ffprobe, noSync)
|
||||||
},
|
},
|
||||||
|
|
@ -134,6 +140,28 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error
|
||||||
return 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
|
// Sync to server
|
||||||
if !noSync {
|
if !noSync {
|
||||||
return syncToServer(ctx, cfg, cache)
|
return syncToServer(ctx, cfg, cache)
|
||||||
|
|
@ -181,6 +209,7 @@ func syncToServer(ctx context.Context, cfg config.Config, cache *library.Library
|
||||||
resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
|
||||||
Items: batch,
|
Items: batch,
|
||||||
ScanPath: cache.Path,
|
ScanPath: cache.Path,
|
||||||
|
AgentID: cfg.Agent.ID,
|
||||||
IsLastBatch: isLast,
|
IsLastBatch: isLast,
|
||||||
SyncStartedAt: syncStartedAt,
|
SyncStartedAt: syncStartedAt,
|
||||||
})
|
})
|
||||||
|
|
@ -236,7 +265,7 @@ func printScanSummary(cache *library.LibraryCache) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
res := library.ResolveResolution(item.MediaInfo.Video.Height)
|
res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
|
||||||
if res == "" {
|
if res == "" {
|
||||||
res = "other"
|
res = "other"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,17 @@ package cmd
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
|
||||||
|
|
||||||
"github.com/fatih/color"
|
"github.com/fatih/color"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
"github.com/torrentclaw/unarr/internal/upgrade"
|
"github.com/torrentclaw/unarr/internal/upgrade"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newSelfUpdateCmd() *cobra.Command {
|
func newSelfUpdateCmd() *cobra.Command {
|
||||||
var force bool
|
var force bool
|
||||||
|
var allowUnsigned bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "self-update",
|
Use: "self-update",
|
||||||
|
|
@ -23,29 +21,35 @@ func newSelfUpdateCmd() *cobra.Command {
|
||||||
Long: `Download and install the latest version of unarr.
|
Long: `Download and install the latest version of unarr.
|
||||||
|
|
||||||
Checks GitHub for the latest release, verifies the checksum, and
|
Checks GitHub for the latest release, verifies the checksum, and
|
||||||
replaces the current binary. A backup is kept at <binary>.backup.`,
|
replaces the current binary. A backup is kept at <binary>.backup.
|
||||||
|
|
||||||
|
If the daemon is running, it is automatically restarted so the new
|
||||||
|
version is loaded into memory (otherwise heartbeat would keep
|
||||||
|
reporting the old version until a manual restart).`,
|
||||||
Example: ` unarr self-update
|
Example: ` unarr self-update
|
||||||
unarr self-update --force`,
|
unarr self-update --force
|
||||||
|
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runSelfUpdate(force)
|
return runSelfUpdate(force, allowUnsigned)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
||||||
|
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
func runSelfUpdate(force bool) error {
|
func runSelfUpdate(force, allowUnsigned bool) error {
|
||||||
bold := color.New(color.Bold)
|
bold := color.New(color.Bold)
|
||||||
green := color.New(color.FgGreen)
|
green := color.New(color.FgGreen)
|
||||||
yellow := color.New(color.FgYellow)
|
yellow := color.New(color.FgYellow)
|
||||||
|
red := color.New(color.FgRed)
|
||||||
|
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
bold.Println(" unarr self-update")
|
bold.Println(" unarr self-update")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Check latest version
|
|
||||||
fmt.Print(" Checking latest version... ")
|
fmt.Print(" Checking latest version... ")
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
latest, err := upgrade.CheckLatest(ctx)
|
latest, err := upgrade.CheckLatest(ctx)
|
||||||
|
|
@ -73,6 +77,7 @@ func runSelfUpdate(force bool) error {
|
||||||
|
|
||||||
upgrader := &upgrade.Upgrader{
|
upgrader := &upgrade.Upgrader{
|
||||||
CurrentVersion: currentClean,
|
CurrentVersion: currentClean,
|
||||||
|
AllowUnsigned: allowUnsigned,
|
||||||
OnProgress: func(msg string) {
|
OnProgress: func(msg string) {
|
||||||
fmt.Printf(" %s\n", msg)
|
fmt.Printf(" %s\n", msg)
|
||||||
},
|
},
|
||||||
|
|
@ -89,37 +94,25 @@ func runSelfUpdate(force bool) error {
|
||||||
if result.BackupPath != "" {
|
if result.BackupPath != "" {
|
||||||
fmt.Printf(" Backup: %s\n", result.BackupPath)
|
fmt.Printf(" Backup: %s\n", result.BackupPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-restart daemon if it is running, otherwise the live process keeps
|
||||||
|
// serving the old version (heartbeat reports old version → web gates
|
||||||
|
// features against the wrong version).
|
||||||
|
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" → Daemon running (PID %d), restarting to load new version...\n", state.PID)
|
||||||
|
if err := runDaemonSvcRestart(); err != nil {
|
||||||
|
fmt.Println()
|
||||||
|
red.Printf(" ✗ Auto-restart failed: %v\n", err)
|
||||||
|
fmt.Println(" The new binary is on disk but the daemon is still running the old version.")
|
||||||
|
fmt.Println(" Run manually: unarr daemon restart")
|
||||||
|
fmt.Println(" (If the daemon runs under a different user/session, restart it there.)")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// If running as daemon, re-exec to restart with new binary
|
|
||||||
// For interactive use, just suggest restarting
|
|
||||||
if isRunningAsDaemon() {
|
|
||||||
fmt.Println(" Restarting daemon with new version...")
|
|
||||||
binPath, err := os.Executable()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not determine executable path: %w", err)
|
|
||||||
}
|
|
||||||
execErr := syscall.Exec(binPath, os.Args, os.Environ())
|
|
||||||
if execErr != nil && runtime.GOOS == "windows" {
|
|
||||||
// Windows doesn't support syscall.Exec — start new process
|
|
||||||
proc := exec.Command(binPath, os.Args[1:]...)
|
|
||||||
proc.Stdout = os.Stdout
|
|
||||||
proc.Stderr = os.Stderr
|
|
||||||
proc.Stdin = os.Stdin
|
|
||||||
return proc.Start()
|
|
||||||
}
|
|
||||||
return execErr
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
green.Println(" ✓ Daemon restarted")
|
||||||
|
}
|
||||||
|
|
||||||
func isRunningAsDaemon() bool {
|
fmt.Println()
|
||||||
// Simple heuristic: check if "start" was in the original args
|
return nil
|
||||||
for _, arg := range os.Args {
|
|
||||||
if arg == "start" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package cmd
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -58,7 +59,7 @@ func runStatus() error {
|
||||||
go func() {
|
go func() {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
|
ac := newAgentClientFromConfig(cfg, "unarr/"+Version)
|
||||||
resp, err := ac.Register(ctx, agent.RegisterRequest{
|
resp, err := ac.Register(ctx, agent.RegisterRequest{
|
||||||
AgentID: cfg.Agent.ID,
|
AgentID: cfg.Agent.ID,
|
||||||
Name: cfg.Agent.Name,
|
Name: cfg.Agent.Name,
|
||||||
|
|
@ -74,7 +75,17 @@ func runStatus() error {
|
||||||
cyan.Println(" Account")
|
cyan.Println(" Account")
|
||||||
ar := <-accountCh
|
ar := <-accountCh
|
||||||
if ar.err != nil {
|
if ar.err != nil {
|
||||||
dim.Println(" Could not fetch account info")
|
var httpErr *agent.HTTPError
|
||||||
|
switch {
|
||||||
|
case errors.As(ar.err, &httpErr) && httpErr.StatusCode == 401:
|
||||||
|
yellow.Println(" API key invalid or revoked")
|
||||||
|
fmt.Printf(" Run %s to re-authenticate\n", cyan.Sprint("unarr login"))
|
||||||
|
case errors.As(ar.err, &httpErr) && httpErr.StatusCode == 403:
|
||||||
|
yellow.Println(" API key lacks permission for this server")
|
||||||
|
fmt.Printf(" Check plan or run %s\n", cyan.Sprint("unarr login"))
|
||||||
|
default:
|
||||||
|
dim.Printf(" Could not fetch account info (%v)\n", ar.err)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf(" User: %s\n", ar.user.Name)
|
fmt.Printf(" User: %s\n", ar.user.Name)
|
||||||
fmt.Printf(" Email: %s\n", ar.user.Email)
|
fmt.Printf(" Email: %s\n", ar.user.Email)
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,17 @@ func cancelStreamTask(taskID string) {
|
||||||
// handleStreamTask manages a streaming task lifecycle for active torrent downloads.
|
// handleStreamTask manages a streaming task lifecycle for active torrent downloads.
|
||||||
// It creates a StreamEngine, buffers, sets the file on the persistent server,
|
// It creates a StreamEngine, buffers, sets the file on the persistent server,
|
||||||
// and reports progress until the task is cancelled or the download completes.
|
// 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) {
|
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config, agentClient *agent.Client, srv *engine.StreamServer, onStateChange func()) {
|
||||||
ctx, cancel := context.WithCancel(parentCtx)
|
ctx, cancel := context.WithCancel(parentCtx)
|
||||||
defer cancel()
|
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
|
// Register for web-initiated cancellation
|
||||||
streamRegistry.mu.Lock()
|
streamRegistry.mu.Lock()
|
||||||
streamRegistry.cancels[at.ID] = cancel
|
streamRegistry.cancels[at.ID] = cancel
|
||||||
|
|
@ -106,10 +113,55 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
|
||||||
}()
|
}()
|
||||||
|
|
||||||
task := engine.NewTaskFromAgent(at)
|
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
|
task.ResolvedMethod = engine.MethodTorrent
|
||||||
reporter.Track(task)
|
reporter.Track(task)
|
||||||
defer reporter.ReportFinal(context.Background(), 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
|
// 1. Create StreamEngine
|
||||||
eng, err := engine.NewStreamEngine(engine.StreamConfig{
|
eng, err := engine.NewStreamEngine(engine.StreamConfig{
|
||||||
DataDir: cfg.Download.Dir,
|
DataDir: cfg.Download.Dir,
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
|
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
|
||||||
func newUpgradeCmd() *cobra.Command {
|
func newUpgradeCmd() *cobra.Command {
|
||||||
var force bool
|
var force bool
|
||||||
|
var allowUnsigned bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "upgrade",
|
Use: "upgrade",
|
||||||
|
|
@ -18,13 +19,15 @@ This is an alias for 'unarr self-update'. Checks GitHub for the latest
|
||||||
release, verifies the checksum, and replaces the current binary.
|
release, verifies the checksum, and replaces the current binary.
|
||||||
A backup is kept at <binary>.backup.`,
|
A backup is kept at <binary>.backup.`,
|
||||||
Example: ` unarr upgrade
|
Example: ` unarr upgrade
|
||||||
unarr upgrade --force`,
|
unarr upgrade --force
|
||||||
|
unarr upgrade --allow-unsigned`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
return runSelfUpdate(force)
|
return runSelfUpdate(force, allowUnsigned)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
|
||||||
|
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package cmd
|
package cmd
|
||||||
|
|
||||||
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
|
||||||
var Version = "0.6.6"
|
var Version = "1.0.2-beta"
|
||||||
|
|
|
||||||
213
internal/cmd/vpn.go
Normal file
213
internal/cmd/vpn.go
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"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/vpn"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newVPNCmd() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "vpn",
|
||||||
|
Short: "Manage the managed-VPN split-tunnel for downloads",
|
||||||
|
Long: `Enable, disable, and inspect the managed VPN.
|
||||||
|
|
||||||
|
When enabled, the daemon fetches a WireGuard config from your TorrentClaw account
|
||||||
|
at startup and routes ONLY the torrent client's traffic (peers + trackers) through
|
||||||
|
an in-process WireGuard tunnel — no root, no OS routing changes.
|
||||||
|
|
||||||
|
This is split-tunnel: your browser and other apps keep using your real IP. Only
|
||||||
|
your downloads are hidden behind the VPN server.
|
||||||
|
|
||||||
|
The VPN requires a PRO+ plan with the VPN add-on. Set it up at
|
||||||
|
https://torrentclaw.com/vpn and configure your other devices (phone, laptop) with
|
||||||
|
the OpenVPN credentials from your profile — those don't share the agent's tunnel.`,
|
||||||
|
Example: ` unarr vpn status # is the tunnel up? which server?
|
||||||
|
unarr vpn enable # turn the managed VPN on
|
||||||
|
unarr vpn disable # turn it off`,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(newVPNStatusCmd(), newVPNEnableCmd(), newVPNDisableCmd())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVPNStatusCmd() *cobra.Command {
|
||||||
|
var check bool
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show VPN configuration and live tunnel state",
|
||||||
|
Example: " unarr vpn status\n unarr vpn status --check # also verify your account is provisioned",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runVPNStatus(check)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.Flags().BoolVar(&check, "check", false, "query the API to verify the VPN is provisioned on your account")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runVPNStatus(check bool) error {
|
||||||
|
bold := color.New(color.Bold)
|
||||||
|
dim := color.New(color.FgHiBlack)
|
||||||
|
green := color.New(color.FgGreen)
|
||||||
|
yellow := color.New(color.FgYellow)
|
||||||
|
cyan := color.New(color.FgCyan)
|
||||||
|
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
bold.Println(" Managed VPN")
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
|
// ── Configured mode ──
|
||||||
|
switch {
|
||||||
|
case cfg.Download.VPN.ConfigFile != "":
|
||||||
|
cyan.Println(" Mode: self-hosted (local config_file)")
|
||||||
|
fmt.Printf(" Config: %s\n", cfg.Download.VPN.ConfigFile)
|
||||||
|
case cfg.Download.VPN.Enabled:
|
||||||
|
cyan.Println(" Mode: managed (config fetched from your account)")
|
||||||
|
default:
|
||||||
|
dim.Println(" Mode: off")
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" Enable with `unarr vpn enable` (needs a PRO+ plan with the VPN add-on).")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live tunnel state (from the daemon state file) ──
|
||||||
|
state := agent.ReadState()
|
||||||
|
alive := state != nil && isDaemonAlive(state)
|
||||||
|
fmt.Println()
|
||||||
|
switch {
|
||||||
|
case alive && state.VPNActive:
|
||||||
|
server := state.VPNServer
|
||||||
|
if host, _, err := net.SplitHostPort(server); err == nil && host != "" {
|
||||||
|
server = host
|
||||||
|
}
|
||||||
|
green.Println(" ✓ Tunnel ACTIVE — torrent traffic is routed through the VPN")
|
||||||
|
if server != "" {
|
||||||
|
fmt.Printf(" Exit server: %s\n", server)
|
||||||
|
}
|
||||||
|
case alive:
|
||||||
|
yellow.Println(" ⚠ Daemon is running but the tunnel is NOT up — downloads go in the clear.")
|
||||||
|
dim.Println(" Check `unarr daemon logs` for a [vpn] line. Common cause: no active")
|
||||||
|
dim.Println(" VPN on your account (set it up at https://torrentclaw.com/vpn).")
|
||||||
|
default:
|
||||||
|
dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Optional live provisioning check ──
|
||||||
|
if check {
|
||||||
|
fmt.Println()
|
||||||
|
if cfg.Auth.APIKey == "" {
|
||||||
|
yellow.Println(" ⚠ No API key — run `unarr init` first.")
|
||||||
|
} else {
|
||||||
|
apiURL := cfg.Auth.APIURL
|
||||||
|
if apiURL == "" {
|
||||||
|
apiURL = "https://torrentclaw.com"
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
_, err := vpn.FetchConfig(ctx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, true)
|
||||||
|
cancel()
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
green.Println(" ✓ Account provisioned — a VPN config is available.")
|
||||||
|
default:
|
||||||
|
yellow.Printf(" ⚠ %s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Split-tunnel reminder ──
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" Split-tunnel: only your downloads use the VPN. Your browser and other")
|
||||||
|
dim.Println(" apps keep your real IP — that's by design. Use the OpenVPN credentials in")
|
||||||
|
dim.Println(" your profile to protect your other devices.")
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVPNEnableCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "enable",
|
||||||
|
Short: "Turn the managed VPN on",
|
||||||
|
Example: " unarr vpn enable",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return setVPNEnabled(true)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVPNDisableCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "disable",
|
||||||
|
Short: "Turn the managed VPN off",
|
||||||
|
Example: " unarr vpn disable",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return setVPNEnabled(false)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVPNEnabled(enabled bool) error {
|
||||||
|
green := color.New(color.FgGreen)
|
||||||
|
yellow := color.New(color.FgYellow)
|
||||||
|
dim := color.New(color.FgHiBlack)
|
||||||
|
|
||||||
|
cfg := loadConfig()
|
||||||
|
|
||||||
|
if enabled && cfg.Auth.APIKey == "" {
|
||||||
|
return fmt.Errorf("no API key configured — run `unarr init` first (the managed VPN fetches its config from your account)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Download.VPN.Enabled == enabled {
|
||||||
|
fmt.Println()
|
||||||
|
dim.Printf(" VPN is already %s — nothing to do.\n", enabledWord(enabled))
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Download.VPN.Enabled = enabled
|
||||||
|
|
||||||
|
configPath := config.FilePath()
|
||||||
|
if cfgFile != "" {
|
||||||
|
configPath = cfgFile
|
||||||
|
}
|
||||||
|
if err := config.Save(cfg, configPath); err != nil {
|
||||||
|
return fmt.Errorf("save config: %w", err)
|
||||||
|
}
|
||||||
|
appCfg = cfg
|
||||||
|
|
||||||
|
fmt.Println()
|
||||||
|
green.Printf(" ✓ Managed VPN %s.\n", enabledWord(enabled))
|
||||||
|
|
||||||
|
if enabled && cfg.Download.VPN.ConfigFile != "" {
|
||||||
|
yellow.Println(" ⚠ A config_file is set, so self-hosted mode takes precedence and the")
|
||||||
|
yellow.Println(" managed config from your account is ignored. Clear config_file to use it.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The tunnel is brought up once at daemon startup; a plain config reload does
|
||||||
|
// NOT (re)create it. Tell the user to restart the daemon if it's running.
|
||||||
|
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
|
||||||
|
fmt.Println()
|
||||||
|
dim.Println(" The daemon is running. Restart it for this to take effect:")
|
||||||
|
dim.Println(" unarr daemon restart")
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func enabledWord(enabled bool) string {
|
||||||
|
if enabled {
|
||||||
|
return "enabled"
|
||||||
|
}
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
)
|
)
|
||||||
|
|
@ -26,6 +27,11 @@ type Config struct {
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
APIKey string `toml:"api_key"`
|
APIKey string `toml:"api_key"`
|
||||||
APIURL string `toml:"api_url"`
|
APIURL string `toml:"api_url"`
|
||||||
|
// Mirrors lists alternate base URLs the agent will fall back to when the
|
||||||
|
// primary api_url is unreachable. Ordered by preference. Refreshed at
|
||||||
|
// runtime by `unarr mirrors update` against /api/v1/mirrors so a long-
|
||||||
|
// running agent survives a primary takedown without a new release.
|
||||||
|
Mirrors []string `toml:"mirrors"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
|
|
@ -38,12 +44,97 @@ type DownloadConfig struct {
|
||||||
PreferredMethod string `toml:"preferred_method"`
|
PreferredMethod string `toml:"preferred_method"`
|
||||||
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
|
||||||
MaxConcurrent int `toml:"max_concurrent"`
|
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
|
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
|
||||||
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "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")
|
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")
|
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)
|
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)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
|
||||||
|
// daemon's HLS server over a public HTTPS hostname (https://<random>.try
|
||||||
|
// cloudflare.com). Enabling it lets the web player on torrentclaw.com play
|
||||||
|
// from this daemon across any network without Tailscale or a public IP —
|
||||||
|
// the cost is that bytes proxy through CloudFlare's network. Off by default.
|
||||||
|
type FunnelConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
|
||||||
|
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
|
||||||
|
// routes only the torrent client's peer/tracker traffic through an in-process
|
||||||
|
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
|
||||||
|
// add-on on the account; otherwise the daemon logs and downloads in the clear.
|
||||||
|
type VPNConfig struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
|
||||||
|
// of fetching one from the web API. For self-hosted / personal-VPN testing:
|
||||||
|
// point it at a peer .conf from your own WireGuard server and the torrent
|
||||||
|
// client split-tunnels through it with no web/provider plumbing.
|
||||||
|
ConfigFile string `toml:"config_file"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscodeConfig controls real-time transcoding for the in-browser player
|
||||||
|
// when source codecs aren't browser-decodable (HEVC, AV1, AC3, DTS, etc.).
|
||||||
|
// 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"`
|
||||||
|
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)
|
||||||
|
MaxConcurrent int `toml:"max_concurrent"` // safety cap on simultaneous transcoder processes
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrganizeConfig struct {
|
type OrganizeConfig struct {
|
||||||
|
|
@ -54,8 +145,32 @@ type OrganizeConfig struct {
|
||||||
|
|
||||||
type DaemonConfig struct {
|
type DaemonConfig struct {
|
||||||
StatusInterval string `toml:"status_interval"`
|
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 {
|
type NotificationsConfig struct {
|
||||||
Enabled bool `toml:"enabled"`
|
Enabled bool `toml:"enabled"`
|
||||||
}
|
}
|
||||||
|
|
@ -70,26 +185,106 @@ type LibraryConfig struct {
|
||||||
ScanPath string `toml:"scan_path"` // remembered from last scan
|
ScanPath string `toml:"scan_path"` // remembered from last scan
|
||||||
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
|
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
|
||||||
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
|
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
|
||||||
|
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
|
||||||
BackupDir string `toml:"backup_dir"` // for replaced files
|
BackupDir string `toml:"backup_dir"` // for replaced files
|
||||||
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
|
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
|
||||||
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
|
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
|
||||||
|
|
||||||
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default returns a Config with sensible defaults.
|
// 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
|
||||||
|
// installs (no config file yet) and as the baseline for Load — fields not
|
||||||
|
// present in the user's TOML keep their Default() value.
|
||||||
func Default() Config {
|
func Default() Config {
|
||||||
return Config{
|
return Config{
|
||||||
Auth: AuthConfig{
|
Auth: AuthConfig{
|
||||||
APIURL: "https://torrentclaw.com",
|
APIURL: "https://torrentclaw.com",
|
||||||
|
// Default mirror list. Kept in sync with src/lib/mirrors-config.ts
|
||||||
|
// on the server. Users can override with `unarr mirrors update`,
|
||||||
|
// which pulls the live list from /api/v1/mirrors.
|
||||||
|
Mirrors: []string{
|
||||||
|
"https://torrentclaw.to",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Download: DownloadConfig{
|
Download: DownloadConfig{
|
||||||
PreferredMethod: "auto",
|
PreferredMethod: "auto",
|
||||||
MaxConcurrent: 3,
|
MaxConcurrent: 3,
|
||||||
|
MinFreeDiskMB: 2048, // 2 GiB reserve
|
||||||
StreamPort: 11818,
|
StreamPort: 11818,
|
||||||
|
HTTPSStreamPort: 11819,
|
||||||
|
RequireStreamToken: true, // secure by default; loopback exempt
|
||||||
|
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: "",
|
||||||
|
AudioBitrate: "192k",
|
||||||
|
MaxConcurrent: 2,
|
||||||
|
},
|
||||||
|
Funnel: FunnelConfig{
|
||||||
|
// On by default so headless installs (NAS / Docker) get cross-network
|
||||||
|
// HTTPS playback without anyone having to terminal in. Users who
|
||||||
|
// don't want bytes proxied through CloudFlare can opt out with
|
||||||
|
// `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{
|
Organize: OrganizeConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
Daemon: DaemonConfig{},
|
|
||||||
Notifications: NotificationsConfig{
|
Notifications: NotificationsConfig{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
},
|
},
|
||||||
|
|
@ -101,6 +296,14 @@ func Default() Config {
|
||||||
AutoScan: true,
|
AutoScan: true,
|
||||||
ScanInterval: "24h",
|
ScanInterval: "24h",
|
||||||
Workers: 8,
|
Workers: 8,
|
||||||
|
CacheSubtitles: true,
|
||||||
|
CacheThumbnails: true,
|
||||||
|
Trickplay: TrickplayConfig{
|
||||||
|
Enabled: true,
|
||||||
|
Interval: "10s",
|
||||||
|
Width: 240,
|
||||||
|
},
|
||||||
|
PrewarmMaxLoadRatio: 0.7,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -123,28 +326,100 @@ func Load(path string) (Config, error) {
|
||||||
return cfg, fmt.Errorf("read config: %w", err)
|
return cfg, fmt.Errorf("read config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := toml.Unmarshal(data, &cfg); err != nil {
|
meta, err := toml.Decode(string(data), &cfg)
|
||||||
|
if err != nil {
|
||||||
return cfg, fmt.Errorf("parse config: %w", err)
|
return cfg, fmt.Errorf("parse config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-apply defaults for zero values that should have defaults
|
applyDefaults(&cfg, meta)
|
||||||
if cfg.Auth.APIURL == "" {
|
return cfg, nil
|
||||||
cfg.Auth.APIURL = "https://torrentclaw.com"
|
|
||||||
}
|
|
||||||
if cfg.Download.PreferredMethod == "" {
|
|
||||||
cfg.Download.PreferredMethod = "auto"
|
|
||||||
}
|
|
||||||
if cfg.Download.MaxConcurrent == 0 {
|
|
||||||
cfg.Download.MaxConcurrent = 3
|
|
||||||
}
|
|
||||||
if cfg.General.Country == "" {
|
|
||||||
cfg.General.Country = "US"
|
|
||||||
}
|
|
||||||
if cfg.Download.StreamPort == 0 {
|
|
||||||
cfg.Download.StreamPort = 11818
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
// applyDefaults fills in sensible defaults for keys that the user did not
|
||||||
|
// define in the TOML file. We use MetaData (rather than zero-value checks) so
|
||||||
|
// that explicitly setting a field to its zero value (e.g. `enabled = false`)
|
||||||
|
// is respected — only truly missing keys get defaulted. This lets a fresh
|
||||||
|
// install work out of the box for streaming without forcing every user to
|
||||||
|
// edit the TOML, while still letting power users disable features.
|
||||||
|
func applyDefaults(cfg *Config, meta toml.MetaData) {
|
||||||
|
if !meta.IsDefined("auth", "api_url") {
|
||||||
|
cfg.Auth.APIURL = "https://torrentclaw.com"
|
||||||
|
}
|
||||||
|
if !meta.IsDefined("auth", "mirrors") {
|
||||||
|
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
|
||||||
|
}
|
||||||
|
if !meta.IsDefined("downloads", "preferred_method") {
|
||||||
|
cfg.Download.PreferredMethod = "auto"
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
if !meta.IsDefined("downloads", "transcode", "hw_accel") {
|
||||||
|
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 = ""
|
||||||
|
}
|
||||||
|
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
|
||||||
|
cfg.Download.Transcode.AudioBitrate = "192k"
|
||||||
|
}
|
||||||
|
if !meta.IsDefined("downloads", "transcode", "max_concurrent") {
|
||||||
|
cfg.Download.Transcode.MaxConcurrent = 2
|
||||||
|
}
|
||||||
|
// NOTE: Funnel default-ON only applies to fresh installs (no config file →
|
||||||
|
// Default() returns Funnel.Enabled=true straight off). When an existing
|
||||||
|
// config file lacks `[downloads.funnel]` entirely we intentionally do NOT
|
||||||
|
// flip it on here — that would silently route an upgraded operator's
|
||||||
|
// traffic through CloudFlare without their consent. They opt in with
|
||||||
|
// `unarr funnel on` whenever they're ready.
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save writes config to the default or specified path using atomic write.
|
// Save writes config to the default or specified path using atomic write.
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,111 @@ func TestParseSpeed(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadMinimalTOMLAppliesStreamingDefaults(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "config.toml")
|
||||||
|
|
||||||
|
// Minimal config — only auth + agent. Nothing about webrtc / transcode.
|
||||||
|
os.WriteFile(path, []byte(`[auth]
|
||||||
|
api_key = "tc_minimal"
|
||||||
|
|
||||||
|
[agent]
|
||||||
|
id = "agent-uuid"
|
||||||
|
name = "Test"
|
||||||
|
`), 0o644)
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transcode should be on by default.
|
||||||
|
if !cfg.Download.Transcode.Enabled {
|
||||||
|
t.Error("Transcode.Enabled should default to true when [downloads.transcode] is absent")
|
||||||
|
}
|
||||||
|
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.MaxConcurrent != 2 {
|
||||||
|
t.Errorf("Transcode.MaxConcurrent = %d, want 2", cfg.Download.Transcode.MaxConcurrent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadRespectsExplicitlyDisabledStreaming(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "config.toml")
|
||||||
|
|
||||||
|
// User explicitly opted out of transcode. Defaults must NOT override
|
||||||
|
// it — that would silently re-enable a feature the user disabled.
|
||||||
|
os.WriteFile(path, []byte(`[downloads.transcode]
|
||||||
|
enabled = false
|
||||||
|
`), 0o644)
|
||||||
|
|
||||||
|
cfg, err := Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Download.Transcode.Enabled {
|
||||||
|
t.Error("Transcode.Enabled = true, want false (user explicitly disabled)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestLoadInvalidTOML(t *testing.T) {
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
path := filepath.Join(tmp, "config.toml")
|
path := filepath.Join(tmp, "config.toml")
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ var httpClient = &http.Client{
|
||||||
type DebridDownloader struct {
|
type DebridDownloader struct {
|
||||||
activeMu sync.Mutex
|
activeMu sync.Mutex
|
||||||
active map[string]context.CancelFunc
|
active map[string]context.CancelFunc
|
||||||
|
|
||||||
|
minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDebridDownloader creates a debrid downloader.
|
// NewDebridDownloader creates a debrid downloader.
|
||||||
|
|
@ -36,6 +38,11 @@ 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 }
|
func (d *DebridDownloader) Method() DownloadMethod { return MethodDebrid }
|
||||||
|
|
||||||
// Available returns true if the task has a direct HTTPS URL from the server.
|
// Available returns true if the task has a direct HTTPS URL from the server.
|
||||||
|
|
@ -167,6 +174,12 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
|
||||||
log.Printf("[%s] starting debrid download: %s", agent.ShortID(task.ID), fileName)
|
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 {
|
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||||
return nil, fmt.Errorf("create directory: %w", err)
|
return nil, fmt.Errorf("create directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
63
internal/engine/diskspace.go
Normal file
63
internal/engine/diskspace.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
56
internal/engine/diskspace_test.go
Normal file
56
internal/engine/diskspace_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
120
internal/engine/encode_benchmark.go
Normal file
120
internal/engine/encode_benchmark.go
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
52
internal/engine/encode_benchmark_test.go
Normal file
52
internal/engine/encode_benchmark_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
1608
internal/engine/hls.go
Normal file
1608
internal/engine/hls.go
Normal file
File diff suppressed because it is too large
Load diff
419
internal/engine/hls_cache.go
Normal file
419
internal/engine/hls_cache.go
Normal file
|
|
@ -0,0 +1,419 @@
|
||||||
|
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))
|
||||||
|
}
|
||||||
134
internal/engine/hls_cache_smoke_test.go
Normal file
134
internal/engine/hls_cache_smoke_test.go
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
//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")
|
||||||
|
}
|
||||||
|
}
|
||||||
364
internal/engine/hls_cache_test.go
Normal file
364
internal/engine/hls_cache_test.go
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
283
internal/engine/hls_test.go
Normal file
283
internal/engine/hls_test.go
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBitrateForQuality(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"2160p": 25_000_000,
|
||||||
|
"1080p": 6_000_000,
|
||||||
|
"720p": 3_500_000,
|
||||||
|
"480p": 1_500_000,
|
||||||
|
"unknown": 6_000_000,
|
||||||
|
"": 6_000_000,
|
||||||
|
}
|
||||||
|
for q, want := range cases {
|
||||||
|
if got := bitrateForQuality(q); got != want {
|
||||||
|
t.Errorf("bitrateForQuality(%q) = %d, want %d", q, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQualityHeight(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"2160p": 2160,
|
||||||
|
"1080p": 1080,
|
||||||
|
"720p": 720,
|
||||||
|
"480p": 480,
|
||||||
|
"": 0,
|
||||||
|
"unknown": 0,
|
||||||
|
}
|
||||||
|
for q, want := range cases {
|
||||||
|
if got := qualityHeight(q); got != want {
|
||||||
|
t.Errorf("qualityHeight(%q) = %d, want %d", q, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScaledDimensions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
srcW, srcH, capH int
|
||||||
|
wantW, wantH int
|
||||||
|
}{
|
||||||
|
{"no_cap_returns_source", 1920, 1080, 0, 1920, 1080},
|
||||||
|
{"under_cap_returns_source", 1280, 720, 1080, 1280, 720},
|
||||||
|
{"4k_capped_to_1080", 3840, 2160, 1080, 1920, 1080},
|
||||||
|
{"even_width_stays_even", 1003, 750, 720, 962, 720},
|
||||||
|
{"odd_width_bumps_up", 1001, 700, 500, 716, 500},
|
||||||
|
{"invalid_returns_default", 0, 0, 0, 1920, 1080},
|
||||||
|
{"negative_returns_default", -10, 100, 0, 1920, 1080},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
gotW, gotH := scaledDimensions(tt.srcW, tt.srcH, tt.capH)
|
||||||
|
if gotW != tt.wantW || gotH != tt.wantH {
|
||||||
|
t.Errorf("scaledDimensions(%d,%d,%d) = (%d,%d), want (%d,%d)",
|
||||||
|
tt.srcW, tt.srcH, tt.capH, gotW, gotH, tt.wantW, tt.wantH)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortHLSID(t *testing.T) {
|
||||||
|
if got := shortHLSID("abcdef1234567890"); got != "abcdef12" {
|
||||||
|
t.Errorf("got %q, want abcdef12", got)
|
||||||
|
}
|
||||||
|
if got := shortHLSID("short"); got != "short" {
|
||||||
|
t.Errorf("got %q, want short", got)
|
||||||
|
}
|
||||||
|
if got := shortHLSID(""); got != "" {
|
||||||
|
t.Errorf("got %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHlsTmpDirRoot(t *testing.T) {
|
||||||
|
root := hlsTmpDirRoot()
|
||||||
|
if root == "" {
|
||||||
|
t.Fatal("hlsTmpDirRoot returned empty")
|
||||||
|
}
|
||||||
|
if !strings.Contains(root, "hls-sessions") && !strings.Contains(root, "unarr-hls-sessions") {
|
||||||
|
t.Errorf("expected path to contain hls-sessions, got %q", root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderVideoPlaylist(t *testing.T) {
|
||||||
|
out := renderVideoPlaylist(10.0, 3)
|
||||||
|
required := []string{
|
||||||
|
"#EXTM3U",
|
||||||
|
"#EXT-X-VERSION:7",
|
||||||
|
"#EXT-X-PLAYLIST-TYPE:VOD",
|
||||||
|
`#EXT-X-MAP:URI="init.mp4"`,
|
||||||
|
"seg-0.m4s",
|
||||||
|
"seg-1.m4s",
|
||||||
|
"seg-2.m4s",
|
||||||
|
"#EXT-X-ENDLIST",
|
||||||
|
}
|
||||||
|
for _, want := range required {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("playlist missing %q\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
if !strings.Contains(out, "#EXTINF:1.500,") {
|
||||||
|
t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMasterPlaylist(t *testing.T) {
|
||||||
|
probe := &StreamProbe{
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
SubtitleTracks: []ProbeSubtitleTrack{
|
||||||
|
{Index: 0, Lang: "es", Codec: "subrip", Title: "Spanish"},
|
||||||
|
{Index: 1, Lang: "en", Codec: "subrip", Title: "English", Forced: true},
|
||||||
|
{Index: 2, Lang: "ja", Codec: "hdmv_pgs_subtitle"}, // bitmap, skipped
|
||||||
|
},
|
||||||
|
}
|
||||||
|
out := renderMasterPlaylist(probe, "1080p")
|
||||||
|
|
||||||
|
if !strings.HasPrefix(out, "#EXTM3U") {
|
||||||
|
t.Errorf("must start with #EXTM3U, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "BANDWIDTH=6000000") {
|
||||||
|
t.Errorf("expected 1080p bandwidth, got:\n%s", out)
|
||||||
|
}
|
||||||
|
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 <track>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 <track>), got:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "EXT-X-MEDIA") {
|
||||||
|
t.Errorf("no EXT-X-MEDIA rendition expected, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderMasterPlaylistNoSubs(t *testing.T) {
|
||||||
|
probe := &StreamProbe{Width: 1280, Height: 720}
|
||||||
|
out := renderMasterPlaylist(probe, "720p")
|
||||||
|
if strings.Contains(out, "SUBTITLES=") {
|
||||||
|
t.Errorf("no subs should produce no SUBTITLES attr, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "BANDWIDTH=3500000") {
|
||||||
|
t.Errorf("expected 720p bandwidth, got:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSSessionRegistry(t *testing.T) {
|
||||||
|
r := NewHLSSessionRegistry()
|
||||||
|
if r.Get("missing") != nil {
|
||||||
|
t.Error("Get on empty registry should return nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
s1 := &HLSSession{cfg: HLSSessionConfig{SessionID: "a"}, lastTouch: time.Now()}
|
||||||
|
r.Register(s1)
|
||||||
|
if got := r.Get("a"); got != s1 {
|
||||||
|
t.Errorf("Get(a) = %v, want %v", got, s1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registering a different session evicts (and Closes) the previous one.
|
||||||
|
s2 := &HLSSession{cfg: HLSSessionConfig{SessionID: "b"}, lastTouch: time.Now()}
|
||||||
|
r.Register(s2)
|
||||||
|
if r.Get("a") != nil {
|
||||||
|
t.Error("registering different session should evict prior entries")
|
||||||
|
}
|
||||||
|
if r.Get("b") != s2 {
|
||||||
|
t.Error("Get(b) should return s2")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Remove("b")
|
||||||
|
if r.Get("b") != nil {
|
||||||
|
t.Error("Remove should drop the session")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSSessionAccessors(t *testing.T) {
|
||||||
|
probe := &StreamProbe{VideoCodec: "h264", Width: 1280, Height: 720}
|
||||||
|
s := &HLSSession{
|
||||||
|
cfg: HLSSessionConfig{SessionID: "abcdef1234"},
|
||||||
|
probe: probe,
|
||||||
|
manifestRoot: "MASTER",
|
||||||
|
manifestVideo: "VIDEO",
|
||||||
|
durationSec: 42.5,
|
||||||
|
lastTouch: time.Now().Add(-1 * time.Hour),
|
||||||
|
}
|
||||||
|
if s.MasterPlaylist() != "MASTER" {
|
||||||
|
t.Errorf("MasterPlaylist mismatch")
|
||||||
|
}
|
||||||
|
if s.VideoPlaylist() != "VIDEO" {
|
||||||
|
t.Errorf("VideoPlaylist mismatch")
|
||||||
|
}
|
||||||
|
if s.DurationSeconds() != 42.5 {
|
||||||
|
t.Errorf("DurationSeconds mismatch")
|
||||||
|
}
|
||||||
|
if s.Probe() != probe {
|
||||||
|
t.Errorf("Probe mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
old := s.lastTouch
|
||||||
|
s.Touch()
|
||||||
|
if !s.lastTouch.After(old) {
|
||||||
|
t.Errorf("Touch did not advance lastTouch")
|
||||||
|
}
|
||||||
|
|
||||||
|
info := s.ProbeInfo()
|
||||||
|
if info["videoCodec"] != "h264" || info["width"] != 1280 {
|
||||||
|
t.Errorf("ProbeInfo missing fields: %v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHLSSessionProbeInfoNil(t *testing.T) {
|
||||||
|
s := &HLSSession{}
|
||||||
|
info := s.ProbeInfo()
|
||||||
|
if len(info) != 0 {
|
||||||
|
t.Errorf("nil probe should produce empty info, got %v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSweepIdle(t *testing.T) {
|
||||||
|
r := NewHLSSessionRegistry()
|
||||||
|
idleSession := &HLSSession{
|
||||||
|
cfg: HLSSessionConfig{SessionID: "old"},
|
||||||
|
lastTouch: time.Now().Add(-2 * hlsSessionTTL),
|
||||||
|
}
|
||||||
|
r.Register(idleSession)
|
||||||
|
if got := r.SweepIdle(); got != 1 {
|
||||||
|
t.Errorf("SweepIdle = %d, want 1", got)
|
||||||
|
}
|
||||||
|
if r.Get("old") != nil {
|
||||||
|
t.Errorf("idle session should have been removed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCleanupHLSOrphanDirsMissingRoot(t *testing.T) {
|
||||||
|
// Directory does not exist — should not error.
|
||||||
|
t.Setenv("XDG_CACHE_HOME", filepath.Join(t.TempDir(), "nonexistent"))
|
||||||
|
if err := CleanupHLSOrphanDirs(); err != nil {
|
||||||
|
t.Errorf("CleanupHLSOrphanDirs on missing root = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidSessionID(t *testing.T) {
|
||||||
|
good := []string{
|
||||||
|
"abc",
|
||||||
|
"7b8c4f12-9d3e-4a1b-9c2f-aabbccddeeff",
|
||||||
|
"ABC_123-xyz",
|
||||||
|
strings.Repeat("a", 128),
|
||||||
|
}
|
||||||
|
bad := []string{
|
||||||
|
"",
|
||||||
|
"../etc/passwd",
|
||||||
|
"foo/bar",
|
||||||
|
"foo\\bar",
|
||||||
|
"foo.bar",
|
||||||
|
"with spaces",
|
||||||
|
"with\nnewline",
|
||||||
|
strings.Repeat("a", 129),
|
||||||
|
"héctor", // non-ascii
|
||||||
|
}
|
||||||
|
for _, id := range good {
|
||||||
|
if !validSessionID.MatchString(id) {
|
||||||
|
t.Errorf("validSessionID rejected good id %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, id := range bad {
|
||||||
|
if validSessionID.MatchString(id) {
|
||||||
|
t.Errorf("validSessionID accepted bad id %q", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
230
internal/engine/hls_url_args_test.go
Normal file
230
internal/engine/hls_url_args_test.go
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
330
internal/engine/hwaccel.go
Normal file
330
internal/engine/hwaccel.go
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HWAccel identifies a hardware-accelerated ffmpeg encoder family.
|
||||||
|
type HWAccel string
|
||||||
|
|
||||||
|
const (
|
||||||
|
HWAccelNone HWAccel = "none"
|
||||||
|
HWAccelNVENC HWAccel = "nvenc" // NVIDIA — h264_nvenc / hevc_nvenc
|
||||||
|
HWAccelQSV HWAccel = "qsv" // Intel Quick Sync — h264_qsv / hevc_qsv
|
||||||
|
HWAccelVAAPI HWAccel = "vaapi" // Linux open-source — h264_vaapi / hevc_vaapi
|
||||||
|
HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS — h264_videotoolbox
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
hwOnce sync.Once
|
||||||
|
hwCache HWAccel
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectHWAccel returns the most capable hardware encoder available on this
|
||||||
|
// host, or HWAccelNone if software-only. Cached after first call — adding /
|
||||||
|
// removing a GPU at runtime is rare and the cost of probing isn't free.
|
||||||
|
func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
|
||||||
|
hwOnce.Do(func() {
|
||||||
|
hwCache = detectHWAccelFresh(ctx, ffmpegPath)
|
||||||
|
})
|
||||||
|
return hwCache
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetHWAccelCache clears the singleton — only used in tests.
|
||||||
|
func ResetHWAccelCache() {
|
||||||
|
hwOnce = sync.Once{}
|
||||||
|
hwCache = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectHWAccelFresh(ctx context.Context, ffmpegPath string) HWAccel {
|
||||||
|
if ffmpegPath == "" {
|
||||||
|
return HWAccelNone
|
||||||
|
}
|
||||||
|
encoders := listFFmpegEncoders(ctx, ffmpegPath)
|
||||||
|
if encoders == "" {
|
||||||
|
return HWAccelNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS — VideoToolbox is always available on Apple Silicon + recent Intel.
|
||||||
|
if runtime.GOOS == "darwin" && strings.Contains(encoders, "h264_videotoolbox") {
|
||||||
|
return HWAccelVideoToolbox
|
||||||
|
}
|
||||||
|
|
||||||
|
// NVIDIA — encoder presence + a CUDA-capable device. We rely on the
|
||||||
|
// existence of the device file rather than running nvidia-smi to keep
|
||||||
|
// startup quick on hosts without nvidia tooling.
|
||||||
|
if strings.Contains(encoders, "h264_nvenc") &&
|
||||||
|
(fileExists("/dev/nvidia0") || hasNvidiaDriver()) {
|
||||||
|
return HWAccelNVENC
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intel Quick Sync — needs /dev/dri (also used by VA-API). Distinguish by
|
||||||
|
// checking whether the QSV-specific encoder is built in.
|
||||||
|
if strings.Contains(encoders, "h264_qsv") && fileExists("/dev/dri/renderD128") {
|
||||||
|
return HWAccelQSV
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux generic VA-API — works on Intel + AMD with mesa drivers.
|
||||||
|
if strings.Contains(encoders, "h264_vaapi") && fileExists("/dev/dri/renderD128") {
|
||||||
|
return HWAccelVAAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
return HWAccelNone
|
||||||
|
}
|
||||||
|
|
||||||
|
func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders")
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasNvidiaDriver() bool {
|
||||||
|
// Cheap proxy — if the user has nvidia-smi on PATH they presumably also
|
||||||
|
// have a working driver / runtime libraries.
|
||||||
|
_, err := exec.LookPath("nvidia-smi")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FFmpegVideoCodec returns the encoder name to pass to `-c:v` for the
|
||||||
|
// requested HW accel + target (h264 or hevc).
|
||||||
|
func (h HWAccel) FFmpegVideoCodec(target string) string {
|
||||||
|
target = strings.ToLower(target)
|
||||||
|
switch h {
|
||||||
|
case HWAccelNVENC:
|
||||||
|
if target == "hevc" {
|
||||||
|
return "hevc_nvenc"
|
||||||
|
}
|
||||||
|
return "h264_nvenc"
|
||||||
|
case HWAccelQSV:
|
||||||
|
if target == "hevc" {
|
||||||
|
return "hevc_qsv"
|
||||||
|
}
|
||||||
|
return "h264_qsv"
|
||||||
|
case HWAccelVAAPI:
|
||||||
|
if target == "hevc" {
|
||||||
|
return "hevc_vaapi"
|
||||||
|
}
|
||||||
|
return "h264_vaapi"
|
||||||
|
case HWAccelVideoToolbox:
|
||||||
|
if target == "hevc" {
|
||||||
|
return "hevc_videotoolbox"
|
||||||
|
}
|
||||||
|
return "h264_videotoolbox"
|
||||||
|
default:
|
||||||
|
// Software fallback. libx264 ships with every ffmpeg build.
|
||||||
|
return "libx264"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// H264LevelForHeight returns the lowest H.264 profile level capable of
|
||||||
|
// encoding a stream at the given output pixel height. Each tier carries
|
||||||
|
// enough macroblock headroom to handle ANAMORPHIC content (up to ~2.4:1
|
||||||
|
// cinemascope) at 30 fps — a fixed 16:9 assumption used to silently bust
|
||||||
|
// the level on a 720p movie shot in 2.4:1 (1728×720 = 4860 MBs > 3.1's
|
||||||
|
// 3600 limit; libx264 logs "frame MB size > level limit" and emits a
|
||||||
|
// corrupt stream).
|
||||||
|
func H264LevelForHeight(height int) string {
|
||||||
|
switch {
|
||||||
|
case height <= 0:
|
||||||
|
// Unknown source — pick a level that covers up to 4K so we never
|
||||||
|
// re-introduce the silent-failure mode that motivated this helper.
|
||||||
|
return "5.1"
|
||||||
|
case height <= 480:
|
||||||
|
return "3.1"
|
||||||
|
case height <= 720:
|
||||||
|
// 4.0 instead of 3.1: covers 720p anamorphic (e.g. 1728×720) +
|
||||||
|
// MB rate up to 245k/s (3.1 caps at 108k/s — broken at 24 fps).
|
||||||
|
return "4.0"
|
||||||
|
case height <= 1080:
|
||||||
|
// 4.1 instead of 4.0: covers 1080p anamorphic + 30 fps (~245k MBs/s).
|
||||||
|
return "4.1"
|
||||||
|
case height <= 1440:
|
||||||
|
return "5.0"
|
||||||
|
case height <= 2160:
|
||||||
|
return "5.1"
|
||||||
|
default:
|
||||||
|
// 4K @ 60 fps and 8K all fall under 6.x.
|
||||||
|
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
|
||||||
|
}
|
||||||
186
internal/engine/hwaccel_test.go
Normal file
186
internal/engine/hwaccel_test.go
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHWAccelFFmpegVideoCodec(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
hw HWAccel
|
||||||
|
target string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{HWAccelNone, "h264", "libx264"},
|
||||||
|
{HWAccelNone, "hevc", "libx264"},
|
||||||
|
{HWAccelNVENC, "h264", "h264_nvenc"},
|
||||||
|
{HWAccelNVENC, "hevc", "hevc_nvenc"},
|
||||||
|
{HWAccelQSV, "h264", "h264_qsv"},
|
||||||
|
{HWAccelQSV, "hevc", "hevc_qsv"},
|
||||||
|
{HWAccelVAAPI, "h264", "h264_vaapi"},
|
||||||
|
{HWAccelVAAPI, "hevc", "hevc_vaapi"},
|
||||||
|
{HWAccelVideoToolbox, "h264", "h264_videotoolbox"},
|
||||||
|
{HWAccelVideoToolbox, "hevc", "hevc_videotoolbox"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := tc.hw.FFmpegVideoCodec(tc.target); got != tc.want {
|
||||||
|
t.Errorf("%s.FFmpegVideoCodec(%q) = %q want %q", tc.hw, tc.target, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectHWAccelEmptyPathReturnsNone(t *testing.T) {
|
||||||
|
ResetHWAccelCache()
|
||||||
|
if got := detectHWAccelFresh(t.Context(), ""); got != HWAccelNone {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/torrentclaw/unarr/internal/agent"
|
"github.com/torrentclaw/unarr/internal/agent"
|
||||||
)
|
)
|
||||||
|
|
@ -33,12 +34,37 @@ type Manager struct {
|
||||||
// Used by the daemon to trigger an immediate sync.
|
// Used by the daemon to trigger an immediate sync.
|
||||||
OnTaskDone func()
|
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.
|
// 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.
|
// The sync goroutine reads and clears this to include final states in the next sync.
|
||||||
recentMu sync.Mutex
|
recentMu sync.Mutex
|
||||||
recentFinished []agent.TaskState
|
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.
|
// NewManager creates a download manager.
|
||||||
func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Downloader) *Manager {
|
func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Downloader) *Manager {
|
||||||
if cfg.MaxConcurrent <= 0 {
|
if cfg.MaxConcurrent <= 0 {
|
||||||
|
|
@ -63,15 +89,35 @@ func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Do
|
||||||
// Submit queues a task for download. Non-blocking if capacity available.
|
// Submit queues a task for download. Non-blocking if capacity available.
|
||||||
func (m *Manager) Submit(ctx context.Context, at agent.Task) {
|
func (m *Manager) Submit(ctx context.Context, at agent.Task) {
|
||||||
task := NewTaskFromAgent(at)
|
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
|
// Per-task cancellable context so CancelTask can unblock the goroutine
|
||||||
taskCtx, taskCancel := context.WithCancel(ctx)
|
taskCtx, taskCancel := context.WithCancel(ctx)
|
||||||
|
|
||||||
m.activeMu.Lock()
|
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.active[task.ID] = task
|
||||||
m.cancels[task.ID] = taskCancel
|
m.cancels[task.ID] = taskCancel
|
||||||
m.activeMu.Unlock()
|
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)
|
m.reporter.Track(task)
|
||||||
|
|
||||||
// Force start: bypass semaphore (like Transmission's "Force Start")
|
// Force start: bypass semaphore (like Transmission's "Force Start")
|
||||||
|
|
@ -176,6 +222,13 @@ func (m *Manager) TaskStates() []agent.TaskState {
|
||||||
|
|
||||||
// recordFinished stores a completed/failed task for the next sync cycle.
|
// recordFinished stores a completed/failed task for the next sync cycle.
|
||||||
func (m *Manager) recordFinished(update agent.StatusUpdate) {
|
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()
|
m.recentMu.Lock()
|
||||||
defer m.recentMu.Unlock()
|
defer m.recentMu.Unlock()
|
||||||
m.recentFinished = append(m.recentFinished, agent.TaskStateFromUpdate(update))
|
m.recentFinished = append(m.recentFinished, agent.TaskStateFromUpdate(update))
|
||||||
|
|
@ -271,6 +324,23 @@ func (m *Manager) Wait() {
|
||||||
|
|
||||||
// Shutdown stops accepting tasks and waits for active downloads to finish.
|
// Shutdown stops accepting tasks and waits for active downloads to finish.
|
||||||
func (m *Manager) Shutdown(ctx context.Context) {
|
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
|
// Wait for goroutines with timeout
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -281,7 +351,7 @@ func (m *Manager) Shutdown(ctx context.Context) {
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-done:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Println("shutdown timeout, cancelling active downloads")
|
log.Println("shutdown timeout, abandoning active downloads")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shutdown all downloaders
|
// Shutdown all downloaders
|
||||||
|
|
@ -291,12 +361,7 @@ func (m *Manager) Shutdown(ctx context.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean active map and cancel functions
|
|
||||||
m.activeMu.Lock()
|
m.activeMu.Lock()
|
||||||
for id, cancel := range m.cancels {
|
|
||||||
cancel()
|
|
||||||
delete(m.cancels, id)
|
|
||||||
}
|
|
||||||
m.active = make(map[string]*Task)
|
m.active = make(map[string]*Task)
|
||||||
m.activeMu.Unlock()
|
m.activeMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
@ -344,6 +409,12 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
|
||||||
close(progressCh)
|
close(progressCh)
|
||||||
|
|
||||||
if err != nil {
|
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
|
// Try fallback
|
||||||
if tryFallback(task, m.downloaders) {
|
if tryFallback(task, m.downloaders) {
|
||||||
log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err)
|
log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err)
|
||||||
|
|
@ -386,6 +457,8 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
|
||||||
close(progressCh)
|
close(progressCh)
|
||||||
|
|
||||||
if err != nil {
|
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())
|
m.fail(ctx, task, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
123
internal/engine/manager_resume_test.go
Normal file
123
internal/engine/manager_resume_test.go
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
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()
|
||||||
|
}
|
||||||
186
internal/engine/probe.go
Normal file
186
internal/engine/probe.go
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/torrentclaw/unarr/internal/library/mediainfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreamProbe summarises the codec / container shape of a file as it relates
|
||||||
|
// to the HLS streaming pipeline. It tells the transcoder whether bytes can
|
||||||
|
// be streamed as-is, just remuxed to fragmented MP4, or fully transcoded.
|
||||||
|
type StreamProbe struct {
|
||||||
|
// VideoCodec lowercased — e.g. "h264", "hevc", "av1", "vp9", "mpeg4".
|
||||||
|
VideoCodec string
|
||||||
|
// AudioCodec lowercased — e.g. "aac", "ac3", "dts", "eac3", "opus".
|
||||||
|
// Reflects the default/first audio track for legacy single-track callers.
|
||||||
|
AudioCodec string
|
||||||
|
// Width / Height of the primary video stream.
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
// BitDepth — 8, 10 or 12. 0 if unknown.
|
||||||
|
BitDepth int
|
||||||
|
// HDR signalling string ("HDR10" / "DV" / "HLG" / etc, or "" for SDR).
|
||||||
|
HDR string
|
||||||
|
// DurationSec is the file length, used to sanity-check seek targets.
|
||||||
|
DurationSec float64
|
||||||
|
// Container is the file extension lowercased (".mp4", ".mkv", ".avi").
|
||||||
|
Container string
|
||||||
|
// AudioTracks lists every audio stream in source order. Index in this
|
||||||
|
// slice == ffmpeg `-map 0:a:N` index (where N starts at 0).
|
||||||
|
AudioTracks []ProbeAudioTrack
|
||||||
|
// SubtitleTracks lists every subtitle stream in source order. Index in
|
||||||
|
// this slice == ffmpeg `-map 0:s:N` index.
|
||||||
|
SubtitleTracks []ProbeSubtitleTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeAudioTrack is a slimmed AudioTrack view tied to ffmpeg stream index.
|
||||||
|
type ProbeAudioTrack struct {
|
||||||
|
Index int // 0-based audio stream index (ffmpeg -map 0:a:Index)
|
||||||
|
Lang string // ISO 639-1
|
||||||
|
Codec string // lowercased
|
||||||
|
Channels int
|
||||||
|
Title string
|
||||||
|
Default bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProbeSubtitleTrack is a slimmed SubtitleTrack view tied to ffmpeg stream index.
|
||||||
|
// Codec discriminates text (srt/ass/webvtt → extract to WebVTT) vs bitmap
|
||||||
|
// (pgs/dvbsub → require burn-in).
|
||||||
|
type ProbeSubtitleTrack struct {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscodeAction tells the streaming pipeline how to feed the file to
|
||||||
|
// the browser <video> element. The decision matrix is documented in the
|
||||||
|
// project plan (Fase 2.5 — Transcoding on-the-fly).
|
||||||
|
type TranscodeAction string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ActionPassthrough — file is already browser-playable as-is. Stream the
|
||||||
|
// raw bytes via ReadAt; no ffmpeg involved.
|
||||||
|
ActionPassthrough TranscodeAction = "passthrough"
|
||||||
|
// ActionRemux — codecs are browser-compatible but the container or moov
|
||||||
|
// placement is not. Run ffmpeg with `-c copy -movflags frag_keyframe`.
|
||||||
|
ActionRemux TranscodeAction = "remux"
|
||||||
|
// ActionRemuxAudio — video is fine but audio needs a re-encode (AC3/DTS
|
||||||
|
// → AAC). `-c:v copy -c:a aac`.
|
||||||
|
ActionRemuxAudio TranscodeAction = "remux-audio"
|
||||||
|
// ActionTranscodeVideo — full re-encode. Used for HEVC/AV1 and any
|
||||||
|
// 10-bit content if the browser refuses the codec.
|
||||||
|
ActionTranscodeVideo TranscodeAction = "transcode-video"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
probe := &StreamProbe{Container: lowerExt(filePath)}
|
||||||
|
if mi.Video != nil {
|
||||||
|
probe.VideoCodec = strings.ToLower(mi.Video.Codec)
|
||||||
|
probe.Width = mi.Video.Width
|
||||||
|
probe.Height = mi.Video.Height
|
||||||
|
probe.BitDepth = mi.Video.BitDepth
|
||||||
|
probe.HDR = mi.Video.HDR
|
||||||
|
probe.DurationSec = mi.Video.Duration
|
||||||
|
}
|
||||||
|
if len(mi.Audio) > 0 {
|
||||||
|
// Default to the first track marked "Default", else the first track.
|
||||||
|
picked := mi.Audio[0]
|
||||||
|
for _, a := range mi.Audio {
|
||||||
|
if a.Default {
|
||||||
|
picked = a
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probe.AudioCodec = strings.ToLower(picked.Codec)
|
||||||
|
probe.AudioTracks = make([]ProbeAudioTrack, 0, len(mi.Audio))
|
||||||
|
for i, a := range mi.Audio {
|
||||||
|
probe.AudioTracks = append(probe.AudioTracks, ProbeAudioTrack{
|
||||||
|
Index: i,
|
||||||
|
Lang: a.Lang,
|
||||||
|
Codec: strings.ToLower(a.Codec),
|
||||||
|
Channels: a.Channels,
|
||||||
|
Title: a.Title,
|
||||||
|
Default: a.Default,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(mi.Subtitles) > 0 {
|
||||||
|
probe.SubtitleTracks = make([]ProbeSubtitleTrack, 0, len(mi.Subtitles))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecideAction maps a probe to the transcoding action the streaming pipeline
|
||||||
|
// should take. Browsers consume MP4/h264+AAC natively; everything else needs
|
||||||
|
// some level of re-shaping.
|
||||||
|
func DecideAction(p *StreamProbe) TranscodeAction {
|
||||||
|
if p == nil {
|
||||||
|
return ActionPassthrough
|
||||||
|
}
|
||||||
|
video := p.VideoCodec
|
||||||
|
audio := p.AudioCodec
|
||||||
|
container := p.Container
|
||||||
|
|
||||||
|
// 10-bit / HDR is a hard no for browser playback even if h264 — needs SW transcode.
|
||||||
|
tenBitOrHDR := p.BitDepth >= 10 || p.HDR != ""
|
||||||
|
|
||||||
|
if !tenBitOrHDR && video == "h264" {
|
||||||
|
if audio == "aac" {
|
||||||
|
if container == ".mp4" {
|
||||||
|
return ActionPassthrough
|
||||||
|
}
|
||||||
|
return ActionRemux
|
||||||
|
}
|
||||||
|
// Audio incompatible (AC3/DTS/TrueHD/EAC3) → remux video, transcode audio.
|
||||||
|
return ActionRemuxAudio
|
||||||
|
}
|
||||||
|
|
||||||
|
// HEVC / AV1 / VP9 / 10-bit / unknown → full re-encode video.
|
||||||
|
return ActionTranscodeVideo
|
||||||
|
}
|
||||||
|
|
||||||
|
func lowerExt(filePath string) string {
|
||||||
|
dot := strings.LastIndex(filePath, ".")
|
||||||
|
if dot < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(filePath[dot:])
|
||||||
|
}
|
||||||
141
internal/engine/probe_cache.go
Normal file
141
internal/engine/probe_cache.go
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
202
internal/engine/probe_cache_test.go
Normal file
202
internal/engine/probe_cache_test.go
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
96
internal/engine/probe_test.go
Normal file
96
internal/engine/probe_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDecideAction(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
p StreamProbe
|
||||||
|
want TranscodeAction
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "MP4 + h264 + AAC = passthrough",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", Container: ".mp4"},
|
||||||
|
want: ActionPassthrough,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MKV + h264 + AAC = remux",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", Container: ".mkv"},
|
||||||
|
want: ActionRemux,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MKV + h264 + AC3 = remux audio",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "ac3", Container: ".mkv"},
|
||||||
|
want: ActionRemuxAudio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MP4 + h264 + EAC3 = remux audio",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "eac3", Container: ".mp4"},
|
||||||
|
want: ActionRemuxAudio,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MKV + HEVC = transcode video",
|
||||||
|
p: StreamProbe{VideoCodec: "hevc", AudioCodec: "aac", Container: ".mkv"},
|
||||||
|
want: ActionTranscodeVideo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "MP4 + AV1 = transcode video",
|
||||||
|
p: StreamProbe{VideoCodec: "av1", AudioCodec: "aac", Container: ".mp4"},
|
||||||
|
want: ActionTranscodeVideo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "h264 10-bit = transcode video (browser refuses)",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", BitDepth: 10, Container: ".mp4"},
|
||||||
|
want: ActionTranscodeVideo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "h264 + HDR10 = transcode video",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", HDR: "HDR10", Container: ".mp4"},
|
||||||
|
want: ActionTranscodeVideo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AVI + h264 + AAC = remux",
|
||||||
|
p: StreamProbe{VideoCodec: "h264", AudioCodec: "aac", Container: ".avi"},
|
||||||
|
want: ActionRemux,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unknown codec = transcode video",
|
||||||
|
p: StreamProbe{VideoCodec: "mpeg4", AudioCodec: "mp3", Container: ".avi"},
|
||||||
|
want: ActionTranscodeVideo,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty probe falls through to transcode (unknown codec)",
|
||||||
|
p: StreamProbe{},
|
||||||
|
want: ActionTranscodeVideo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := DecideAction(&tc.p)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("got %s, want %s", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDecideActionNil(t *testing.T) {
|
||||||
|
if DecideAction(nil) != ActionPassthrough {
|
||||||
|
t.Error("nil probe should default passthrough")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLowerExt(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"foo.MP4": ".mp4",
|
||||||
|
"path/to/movie.MKV": ".mkv",
|
||||||
|
"weird.name.with.dots": ".dots",
|
||||||
|
"": "",
|
||||||
|
"noext": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := lowerExt(in); got != want {
|
||||||
|
t.Errorf("lowerExt(%q) = %q want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -45,10 +45,19 @@ type ProgressReporter struct {
|
||||||
lastCheckAt time.Time // last time we reported for control-signal polling
|
lastCheckAt time.Time // last time we reported for control-signal polling
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProgressReporter creates a reporter that flushes every interval.
|
// 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.
|
||||||
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
|
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
|
||||||
|
var rep StatusReporter
|
||||||
|
if ac != nil {
|
||||||
|
rep = ac
|
||||||
|
}
|
||||||
return &ProgressReporter{
|
return &ProgressReporter{
|
||||||
reporter: ac,
|
reporter: rep,
|
||||||
interval: interval,
|
interval: interval,
|
||||||
latest: make(map[string]*Task),
|
latest: make(map[string]*Task),
|
||||||
lastReported: make(map[string]TaskStatus),
|
lastReported: make(map[string]TaskStatus),
|
||||||
|
|
@ -108,6 +117,9 @@ func (r *ProgressReporter) Run(ctx context.Context) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ProgressReporter) flush(ctx context.Context) {
|
func (r *ProgressReporter) flush(ctx context.Context) {
|
||||||
|
if r.reporter == nil {
|
||||||
|
return // local-only reporter (one-shot): nothing to send
|
||||||
|
}
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
tasks := make([]*Task, 0, len(r.latest))
|
tasks := make([]*Task, 0, len(r.latest))
|
||||||
for _, t := range r.latest {
|
for _, t := range r.latest {
|
||||||
|
|
@ -239,6 +251,10 @@ func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse
|
||||||
|
|
||||||
// ReportFinal sends a final status update for a completed/failed task.
|
// ReportFinal sends a final status update for a completed/failed task.
|
||||||
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *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()
|
update := task.ToStatusUpdate()
|
||||||
if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
|
if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
|
||||||
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
|
log.Printf("[%s] final report failed: %v", task.ID[:8], err)
|
||||||
|
|
|
||||||
32
internal/engine/readahead.go
Normal file
32
internal/engine/readahead.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
43
internal/engine/readahead_test.go
Normal file
43
internal/engine/readahead_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
118
internal/engine/seed_lifecycle_smoke_test.go
Normal file
118
internal/engine/seed_lifecycle_smoke_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
//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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -235,7 +235,9 @@ func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered,
|
||||||
func (s *StreamEngine) NewFileReader(ctx context.Context) io.ReadSeekCloser {
|
func (s *StreamEngine) NewFileReader(ctx context.Context) io.ReadSeekCloser {
|
||||||
reader := s.file.NewReader()
|
reader := s.file.NewReader()
|
||||||
reader.SetResponsive()
|
reader.SetResponsive()
|
||||||
reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead
|
// 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.SetContext(ctx)
|
reader.SetContext(ctx)
|
||||||
return reader
|
return reader
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
internal/engine/stream_growing_test.go
Normal file
197
internal/engine/stream_growing_test.go
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
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, but estimate says 100. The advertised
|
||||||
|
// total is the estimate (scrubber timeline); 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) {
|
||||||
|
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_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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,14 +4,23 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenPlayer attempts to open a media player with the given stream URL.
|
// OpenPlayer attempts to open a media player with the given stream URL.
|
||||||
// Returns the player name and the running command.
|
// Returns the player name and the running command.
|
||||||
// If override is set, it uses that command directly.
|
// If override is set, it uses that command directly.
|
||||||
|
//
|
||||||
|
// The URL is required to be http(s) so a hostile-looking value (e.g. starting
|
||||||
|
// with `--`) is not interpreted as a switch by mpv/vlc/xdg-open/open. The
|
||||||
|
// `--` separator is also appended before the URL where the helper supports
|
||||||
|
// it.
|
||||||
func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
||||||
|
if !isSafePlayerURL(url) {
|
||||||
|
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
|
||||||
|
}
|
||||||
if override != "" {
|
if override != "" {
|
||||||
cmd := exec.Command(override, url)
|
cmd := exec.Command(override, "--", url)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return override, nil, fmt.Errorf("start %s: %w", override, err)
|
return override, nil, fmt.Errorf("start %s: %w", override, err)
|
||||||
}
|
}
|
||||||
|
|
@ -20,7 +29,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
||||||
|
|
||||||
// Try mpv first (best streaming support)
|
// Try mpv first (best streaming support)
|
||||||
if path, err := exec.LookPath("mpv"); err == nil {
|
if path, err := exec.LookPath("mpv"); err == nil {
|
||||||
cmd := exec.Command(path, "--no-terminal", url)
|
cmd := exec.Command(path, "--no-terminal", "--", url)
|
||||||
if err := cmd.Start(); err == nil {
|
if err := cmd.Start(); err == nil {
|
||||||
return "mpv", cmd, nil
|
return "mpv", cmd, nil
|
||||||
}
|
}
|
||||||
|
|
@ -28,7 +37,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
||||||
|
|
||||||
// Try VLC
|
// Try VLC
|
||||||
if path, err := exec.LookPath("vlc"); err == nil {
|
if path, err := exec.LookPath("vlc"); err == nil {
|
||||||
cmd := exec.Command(path, url)
|
cmd := exec.Command(path, "--", url)
|
||||||
if err := cmd.Start(); err == nil {
|
if err := cmd.Start(); err == nil {
|
||||||
return "vlc", cmd, nil
|
return "vlc", cmd, nil
|
||||||
}
|
}
|
||||||
|
|
@ -36,7 +45,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
||||||
|
|
||||||
// Try cvlc (VLC headless)
|
// Try cvlc (VLC headless)
|
||||||
if path, err := exec.LookPath("cvlc"); err == nil {
|
if path, err := exec.LookPath("cvlc"); err == nil {
|
||||||
cmd := exec.Command(path, url)
|
cmd := exec.Command(path, "--", url)
|
||||||
if err := cmd.Start(); err == nil {
|
if err := cmd.Start(); err == nil {
|
||||||
return "vlc (headless)", cmd, nil
|
return "vlc (headless)", cmd, nil
|
||||||
}
|
}
|
||||||
|
|
@ -51,6 +60,9 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func openBrowser(url string) (string, *exec.Cmd, error) {
|
func openBrowser(url string) (string, *exec.Cmd, error) {
|
||||||
|
if !isSafePlayerURL(url) {
|
||||||
|
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
|
||||||
|
}
|
||||||
switch runtime.GOOS {
|
switch runtime.GOOS {
|
||||||
case "linux":
|
case "linux":
|
||||||
if path, err := exec.LookPath("xdg-open"); err == nil {
|
if path, err := exec.LookPath("xdg-open"); err == nil {
|
||||||
|
|
@ -60,7 +72,7 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "darwin":
|
case "darwin":
|
||||||
cmd := exec.Command("/usr/bin/open", url)
|
cmd := exec.Command("/usr/bin/open", "--", url)
|
||||||
if err := cmd.Start(); err == nil {
|
if err := cmd.Start(); err == nil {
|
||||||
return "browser", cmd, nil
|
return "browser", cmd, nil
|
||||||
}
|
}
|
||||||
|
|
@ -72,3 +84,9 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
|
||||||
}
|
}
|
||||||
return "", nil, fmt.Errorf("no browser opener found")
|
return "", nil, fmt.Errorf("no browser opener found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isSafePlayerURL guards the helpers above against URLs that could be
|
||||||
|
// interpreted as command-line switches by the launched player.
|
||||||
|
func isSafePlayerURL(url string) bool {
|
||||||
|
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
119
internal/engine/stream_server_extra_test.go
Normal file
119
internal/engine/stream_server_extra_test.go
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStreamServerURLsJSON(t *testing.T) {
|
||||||
|
ss := &StreamServer{}
|
||||||
|
ss.urls = StreamURLs{LAN: "http://10.0.0.1:8000/stream", Tailscale: "http://100.64.0.1:8000/stream"}
|
||||||
|
got := ss.URLsJSON()
|
||||||
|
if !strings.Contains(got, `"lan":"http://10.0.0.1:8000/stream"`) {
|
||||||
|
t.Errorf("URLsJSON missing LAN: %s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, `"ts":"http://100.64.0.1:8000/stream"`) {
|
||||||
|
t.Errorf("URLsJSON missing Tailscale: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamServerHLSBaseURLs(t *testing.T) {
|
||||||
|
ss := &StreamServer{}
|
||||||
|
ss.urls = StreamURLs{
|
||||||
|
LAN: "http://10.0.0.1:8000/stream",
|
||||||
|
Tailscale: "http://100.64.0.1:8000/stream",
|
||||||
|
Public: "http://1.2.3.4:9000/stream",
|
||||||
|
}
|
||||||
|
out := ss.hlsBaseURLs("sess-1")
|
||||||
|
if out.LAN != "http://10.0.0.1:8000/hls/sess-1" {
|
||||||
|
t.Errorf("LAN swap = %q", out.LAN)
|
||||||
|
}
|
||||||
|
if out.Tailscale != "http://100.64.0.1:8000/hls/sess-1" {
|
||||||
|
t.Errorf("Tailscale swap = %q", out.Tailscale)
|
||||||
|
}
|
||||||
|
if out.Public != "http://1.2.3.4:9000/hls/sess-1" {
|
||||||
|
t.Errorf("Public swap = %q", out.Public)
|
||||||
|
}
|
||||||
|
|
||||||
|
js := ss.HLSURLsJSON("sess-1")
|
||||||
|
if !strings.Contains(js, "/hls/sess-1") {
|
||||||
|
t.Errorf("HLSURLsJSON output unexpected: %s", js)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamServerIdleSinceZeroBeforeActivity(t *testing.T) {
|
||||||
|
ss := &StreamServer{}
|
||||||
|
if got := ss.IdleSince(); got != 0 {
|
||||||
|
t.Errorf("IdleSince before any activity = %v, want 0", got)
|
||||||
|
}
|
||||||
|
ss.lastActivity.Store(time.Now().Add(-1 * time.Second).UnixNano())
|
||||||
|
if got := ss.IdleSince(); got <= 0 {
|
||||||
|
t.Errorf("IdleSince after activity should be > 0, got %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskFileProvider(t *testing.T) {
|
||||||
|
tmp := t.TempDir() + "/movie.mp4"
|
||||||
|
data := []byte("hello stream")
|
||||||
|
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
p := NewDiskFileProvider(tmp)
|
||||||
|
if got := p.FileName(); got != "movie.mp4" {
|
||||||
|
t.Errorf("FileName = %q", got)
|
||||||
|
}
|
||||||
|
if got := p.FileSize(); got != int64(len(data)) {
|
||||||
|
t.Errorf("FileSize = %d, want %d", got, len(data))
|
||||||
|
}
|
||||||
|
rdr := p.NewFileReader(context.Background())
|
||||||
|
if rdr == nil {
|
||||||
|
t.Fatal("NewFileReader = nil")
|
||||||
|
}
|
||||||
|
defer rdr.Close()
|
||||||
|
buf := make([]byte, len(data))
|
||||||
|
n, _ := rdr.Read(buf)
|
||||||
|
if string(buf[:n]) != string(data) {
|
||||||
|
t.Errorf("read = %q, want %q", buf[:n], data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskFileProviderMissing(t *testing.T) {
|
||||||
|
p := NewDiskFileProvider("/nonexistent/file.mp4")
|
||||||
|
if rdr := p.NewFileReader(context.Background()); rdr != nil {
|
||||||
|
t.Errorf("NewFileReader on missing file should return nil")
|
||||||
|
}
|
||||||
|
if got := p.FileSize(); got != 0 {
|
||||||
|
t.Errorf("FileSize on missing file = %d, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindVideoFile(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
os.WriteFile(tmp+"/readme.txt", make([]byte, 1000), 0o644) //nolint:errcheck
|
||||||
|
os.WriteFile(tmp+"/sample.mkv", make([]byte, 10*1024*1024), 0o644) //nolint:errcheck
|
||||||
|
os.WriteFile(tmp+"/clip.mp4", make([]byte, 1024*1024), 0o644) //nolint:errcheck
|
||||||
|
os.MkdirAll(tmp+"/sub", 0o755) //nolint:errcheck
|
||||||
|
os.WriteFile(tmp+"/sub/extra.mp4", make([]byte, 5*1024*1024), 0o644) //nolint:errcheck
|
||||||
|
|
||||||
|
got := FindVideoFile(tmp)
|
||||||
|
if !strings.HasSuffix(got, "sample.mkv") {
|
||||||
|
t.Errorf("FindVideoFile = %q, want largest *.mkv", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindVideoFileEmpty(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
if got := FindVideoFile(tmp); got != "" {
|
||||||
|
t.Errorf("FindVideoFile on empty dir = %q, want ''", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLanIPReturnsValidOrEmpty(t *testing.T) {
|
||||||
|
ip := LanIP()
|
||||||
|
if ip != "" && !strings.Contains(ip, ".") && !strings.Contains(ip, ":") {
|
||||||
|
t.Errorf("LanIP returned non-empty non-IP: %q", ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
@ -379,6 +380,149 @@ func TestStreamServer_Health_WithFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStreamServer_Health_NonLoopback_NoLeak verifica que /health no revela
|
||||||
|
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
|
||||||
|
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
|
||||||
|
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
|
||||||
|
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := srv.Listen(ctx); err != nil {
|
||||||
|
t.Fatalf("Listen() error: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Shutdown(ctx)
|
||||||
|
|
||||||
|
provider := newFakeProvider("secret.mkv", []byte("data"))
|
||||||
|
srv.SetFile(provider, "secret-task-id")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
remoteAddr string
|
||||||
|
}{
|
||||||
|
{"lan_ipv4", "192.168.1.50:54321"},
|
||||||
|
{"empty_host_no_bypass", ":54321"},
|
||||||
|
{"public_ipv4", "203.0.113.10:443"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
req.RemoteAddr = tc.remoteAddr
|
||||||
|
srv.healthHandler(rr, req)
|
||||||
|
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, `"status":"ok"`) {
|
||||||
|
t.Errorf("body missing status:ok: %q", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, `"streaming":true`) {
|
||||||
|
t.Errorf("body should report streaming bool: %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "secret.mkv") {
|
||||||
|
t.Errorf("body leaked filename: %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "secret-t") {
|
||||||
|
t.Errorf("body leaked task id: %q", body)
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "192.168.1.50") || strings.Contains(body, "203.0.113.10") {
|
||||||
|
t.Errorf("body leaked client ip: %q", body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreamServer_CORS_Allowlist verifica que sólo los origenes en la
|
||||||
|
// allowlist reciben Access-Control-Allow-Origin y que ningún otro origen
|
||||||
|
// es eco-reflejado.
|
||||||
|
func TestStreamServer_CORS_Allowlist(t *testing.T) {
|
||||||
|
srv := NewStreamServer(0)
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := srv.Listen(ctx); err != nil {
|
||||||
|
t.Fatalf("Listen: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Shutdown(ctx)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
origin string
|
||||||
|
wantAllow bool
|
||||||
|
}{
|
||||||
|
{"https://app.torrentclaw.com", true},
|
||||||
|
{"https://torrentclaw.com", true},
|
||||||
|
{"http://localhost:3030", true},
|
||||||
|
{"http://127.0.0.1:3030", true},
|
||||||
|
{"https://evil.example", false},
|
||||||
|
{"null", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.origin, func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodOptions, "/health", nil)
|
||||||
|
if tc.origin != "" {
|
||||||
|
req.Header.Set("Origin", tc.origin)
|
||||||
|
}
|
||||||
|
srv.healthHandler(rr, req)
|
||||||
|
got := rr.Header().Get("Access-Control-Allow-Origin")
|
||||||
|
if tc.wantAllow {
|
||||||
|
if got != tc.origin {
|
||||||
|
t.Errorf("origin %q: ACAO = %q, want %q", tc.origin, got, tc.origin)
|
||||||
|
}
|
||||||
|
} else if got != "" {
|
||||||
|
t.Errorf("origin %q: ACAO leaked as %q, expected empty", tc.origin, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreamServer_CORS_ExtraOrigin verifica que SetCORSAllowedOrigins añade
|
||||||
|
// origins al baseline sin removerlos.
|
||||||
|
func TestStreamServer_CORS_ExtraOrigin(t *testing.T) {
|
||||||
|
srv := NewStreamServer(0)
|
||||||
|
srv.SetCORSAllowedOrigins([]string{"https://custom.example"})
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := srv.Listen(ctx); err != nil {
|
||||||
|
t.Fatalf("Listen: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Shutdown(ctx)
|
||||||
|
|
||||||
|
for _, origin := range []string{"https://custom.example", "https://torrentclaw.com"} {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
req.Header.Set("Origin", origin)
|
||||||
|
srv.healthHandler(rr, req)
|
||||||
|
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != origin {
|
||||||
|
t.Errorf("origin %q: ACAO = %q", origin, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStreamServer_HLS_InvalidSessionID verifica que el hlsHandler rechaza
|
||||||
|
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
|
||||||
|
// inexistente) para no filtrar el formato aceptado a un attacker.
|
||||||
|
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
|
||||||
|
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
|
||||||
|
ctx := context.Background()
|
||||||
|
if err := srv.Listen(ctx); err != nil {
|
||||||
|
t.Fatalf("Listen() error: %v", err)
|
||||||
|
}
|
||||||
|
defer srv.Shutdown(ctx)
|
||||||
|
|
||||||
|
bad := []string{
|
||||||
|
"/hls/..%2Fetc%2Fpasswd/master.m3u8",
|
||||||
|
"/hls/foo.bar/master.m3u8",
|
||||||
|
"/hls/foo%20bar/master.m3u8",
|
||||||
|
"/hls/foo%2Fbar/master.m3u8",
|
||||||
|
}
|
||||||
|
for _, path := range bad {
|
||||||
|
t.Run(path, func(t *testing.T) {
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
srv.hlsHandler(rr, req)
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("path %q: status = %d, want 404", path, rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
|
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
|
||||||
// es el correcto.
|
// es el correcto.
|
||||||
func TestStreamServer_MKV_ContentType(t *testing.T) {
|
func TestStreamServer_MKV_ContentType(t *testing.T) {
|
||||||
|
|
|
||||||
151
internal/engine/stream_server_tls_test.go
Normal file
151
internal/engine/stream_server_tls_test.go
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
393
internal/engine/stream_source.go
Normal file
393
internal/engine/stream_source.go
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// streamSource abstracts the byte source consumed by the HLS transcoder.
|
||||||
|
// Two implementations:
|
||||||
|
// - diskFileSource — direct passthrough of the on-disk file.
|
||||||
|
// - transcodeSource — ffmpeg writes a fragmented MP4 to a temp file in
|
||||||
|
// real time; reads block briefly when callers ask for bytes ahead of
|
||||||
|
// the writer.
|
||||||
|
type streamSource interface {
|
||||||
|
ReadAt(p []byte, off int64) (int, error)
|
||||||
|
// Size returns the currently known size. For transcoded sources this
|
||||||
|
// grows as ffmpeg produces output; on Final() it's the final size.
|
||||||
|
Size() int64
|
||||||
|
// Final reports whether the source size is now stable (passthrough is
|
||||||
|
// always final, transcoder becomes final when ffmpeg exits).
|
||||||
|
Final() bool
|
||||||
|
// EstimatedSize returns the final size we expect to converge on. For
|
||||||
|
// passthrough it's the same as Size(). For transcoder it's a bitrate
|
||||||
|
// × duration estimate so the browser scrubber has something to anchor
|
||||||
|
// on; the real size will differ ±20%.
|
||||||
|
EstimatedSize() int64
|
||||||
|
FileName() string
|
||||||
|
Transcoded() bool
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// disk passthrough
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type diskFileSource struct {
|
||||||
|
f *os.File
|
||||||
|
size int64
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiskFileSource(path string) (*diskFileSource, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("stream source: open %s: %w", path, err)
|
||||||
|
}
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
f.Close()
|
||||||
|
return nil, fmt.Errorf("stream source: stat %s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &diskFileSource{f: f, size: stat.Size(), name: filepath.Base(path)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *diskFileSource) ReadAt(p []byte, off int64) (int, error) {
|
||||||
|
return d.f.ReadAt(p, off)
|
||||||
|
}
|
||||||
|
func (d *diskFileSource) Size() int64 { return d.size }
|
||||||
|
func (d *diskFileSource) Final() bool { return true }
|
||||||
|
func (d *diskFileSource) EstimatedSize() int64 { return d.size }
|
||||||
|
func (d *diskFileSource) FileName() string { return d.name }
|
||||||
|
func (d *diskFileSource) Transcoded() bool { return false }
|
||||||
|
func (d *diskFileSource) Close() error { return d.f.Close() }
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// transcode source — ffmpeg → tmp file
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type transcodeSource struct {
|
||||||
|
tmpPath string
|
||||||
|
tmpFile *os.File
|
||||||
|
cmd *Transcoder
|
||||||
|
name string
|
||||||
|
estimate int64
|
||||||
|
|
||||||
|
ctx context.Context
|
||||||
|
notify chan struct{} // size grew or final flipped; cap=1, non-blocking send
|
||||||
|
size atomic.Int64
|
||||||
|
final atomic.Bool
|
||||||
|
failure atomic.Pointer[error]
|
||||||
|
startedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// readBlockTimeout caps how long ReadAt waits for bytes that haven't
|
||||||
|
// been transcoded yet before returning EOF/io.ErrUnexpectedEOF. The
|
||||||
|
// pump treats EOF as "respond with whatever we have so far + RangeEnd"
|
||||||
|
// so the browser can re-request once more bytes appear.
|
||||||
|
readBlockTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTranscodeSource(
|
||||||
|
ctx context.Context,
|
||||||
|
srcPath string,
|
||||||
|
probe *StreamProbe,
|
||||||
|
action TranscodeAction,
|
||||||
|
opts TranscodeOpts,
|
||||||
|
displayName string,
|
||||||
|
) (*transcodeSource, error) {
|
||||||
|
tmpFile, err := os.CreateTemp("", "tc-stream-*.mp4")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("transcode source: tmp file: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmpFile.Name()
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
args := buildFFmpegArgs(srcPath, opts)
|
||||||
|
// Override -f mp4 pipe:1 with output to our tmp file path (last 3 args).
|
||||||
|
if len(args) >= 3 && args[len(args)-1] == "pipe:1" {
|
||||||
|
args[len(args)-1] = tmpPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn ffmpeg directly (not via NewTranscoder pipe) so it writes to
|
||||||
|
// disk in real time. We re-use the rest of TranscodeOpts wiring.
|
||||||
|
cmd, err := startTranscoderToFile(ctx, opts.FFmpegPath, args, nil)
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := &transcodeSource{
|
||||||
|
tmpPath: tmpPath,
|
||||||
|
cmd: cmd,
|
||||||
|
name: displayName,
|
||||||
|
estimate: estimate,
|
||||||
|
ctx: ctx,
|
||||||
|
notify: make(chan struct{}, 1),
|
||||||
|
startedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-open the tmp file for reading; ffmpeg keeps writing to it.
|
||||||
|
rf, err := os.Open(tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
_ = cmd.Close()
|
||||||
|
os.Remove(tmpPath)
|
||||||
|
return nil, fmt.Errorf("transcode source: reopen tmp: %w", err)
|
||||||
|
}
|
||||||
|
t.tmpFile = rf
|
||||||
|
|
||||||
|
go t.watchSize(ctx)
|
||||||
|
go t.watchExit()
|
||||||
|
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
|
||||||
|
// produces correct behaviour).
|
||||||
|
func (t *transcodeSource) signalNotify() {
|
||||||
|
select {
|
||||||
|
case t.notify <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchSize polls the temp file size every 200 ms and wakes any blocked
|
||||||
|
// ReadAt callers once new bytes arrive.
|
||||||
|
func (t *transcodeSource) watchSize(ctx context.Context) {
|
||||||
|
ticker := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.signalNotify()
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
if t.final.Load() {
|
||||||
|
t.signalNotify()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stat, err := os.Stat(t.tmpPath)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// watchExit waits for ffmpeg to exit (via Transcoder's single-Wait goroutine)
|
||||||
|
// and locks in the final size. A kill triggered by Close() is NOT a failure.
|
||||||
|
func (t *transcodeSource) watchExit() {
|
||||||
|
<-t.cmd.Done()
|
||||||
|
err := t.cmd.WaitErr()
|
||||||
|
if err != nil && !t.cmd.IsClosing() {
|
||||||
|
failure := fmt.Errorf("ffmpeg exited: %w (%s)", err, t.cmd.Stderr())
|
||||||
|
t.failure.Store(&failure)
|
||||||
|
}
|
||||||
|
if stat, err := os.Stat(t.tmpPath); err == nil {
|
||||||
|
t.size.Store(stat.Size())
|
||||||
|
}
|
||||||
|
t.final.Store(true)
|
||||||
|
t.signalNotify()
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadFailure returns the current failure (or nil) without taking a lock.
|
||||||
|
func (t *transcodeSource) loadFailure() error {
|
||||||
|
if p := t.failure.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcodeSource) ReadAt(p []byte, off int64) (int, error) {
|
||||||
|
if err := t.loadFailure(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if len(p) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if off < 0 {
|
||||||
|
return 0, fmt.Errorf("transcode source: negative offset %d", off)
|
||||||
|
}
|
||||||
|
want := int64(len(p))
|
||||||
|
|
||||||
|
deadline := time.Now().Add(readBlockTimeout)
|
||||||
|
for {
|
||||||
|
if t.final.Load() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
size := t.size.Load()
|
||||||
|
// Overflow-safe form of "off + want <= size":
|
||||||
|
if size >= off && size-off >= want {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
remaining := time.Until(deadline)
|
||||||
|
if remaining <= 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
wait := 500 * time.Millisecond
|
||||||
|
if remaining < wait {
|
||||||
|
wait = remaining
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-t.ctx.Done():
|
||||||
|
return 0, t.ctx.Err()
|
||||||
|
case <-t.notify:
|
||||||
|
case <-time.After(wait):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := t.loadFailure(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := t.tmpFile.ReadAt(p, off)
|
||||||
|
// On a growing file ReadAt returns io.EOF when reading past current size.
|
||||||
|
// Translate that into "send what we have, RangeEnd will follow" by
|
||||||
|
// returning (n, nil) so the pump treats the data as a partial chunk and
|
||||||
|
// caller re-requests once more bytes appear. Only true EOF (final=true)
|
||||||
|
// propagates as io.EOF.
|
||||||
|
if err == io.EOF && !t.final.Load() {
|
||||||
|
if n > 0 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
return 0, errors.New("transcode source: read timed out waiting for ffmpeg output")
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *transcodeSource) Size() int64 { return t.size.Load() }
|
||||||
|
func (t *transcodeSource) Final() bool { return t.final.Load() }
|
||||||
|
func (t *transcodeSource) EstimatedSize() int64 {
|
||||||
|
if t.final.Load() {
|
||||||
|
return t.size.Load()
|
||||||
|
}
|
||||||
|
return t.estimate
|
||||||
|
}
|
||||||
|
func (t *transcodeSource) FileName() string {
|
||||||
|
// Output is always fragmented MP4 regardless of source extension.
|
||||||
|
return strings.TrimSuffix(t.name, filepath.Ext(t.name)) + ".mp4"
|
||||||
|
}
|
||||||
|
func (t *transcodeSource) Transcoded() bool { return true }
|
||||||
|
func (t *transcodeSource) Close() error {
|
||||||
|
var errs []error
|
||||||
|
if err := t.cmd.Close(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
if t.tmpFile != nil {
|
||||||
|
if err := t.tmpFile.Close(); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if t.tmpPath != "" {
|
||||||
|
if err := os.Remove(t.tmpPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateOutputSize converts probed bitrate × duration into a byte estimate
|
||||||
|
// so the browser scrubber has something to anchor on while transcoding.
|
||||||
|
func estimateOutputSize(probe *StreamProbe, opts TranscodeOpts) int64 {
|
||||||
|
if probe == nil || probe.DurationSec <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
videoKbps := parseBitrateKbps(opts.VideoBitrate, 5000)
|
||||||
|
audioKbps := parseBitrateKbps(opts.AudioBitrate, 192)
|
||||||
|
totalKbps := videoKbps + audioKbps
|
||||||
|
bytesPerSec := int64(totalKbps) * 1000 / 8
|
||||||
|
return int64(probe.DurationSec) * bytesPerSec
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBitrateKbps converts ffmpeg-style bitrate strings ("5M", "192k") to
|
||||||
|
// kilobits per second. Unknown formats fall back to fallback.
|
||||||
|
func parseBitrateKbps(s string, fallback int) int {
|
||||||
|
if s == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
last := s[len(s)-1]
|
||||||
|
num := s
|
||||||
|
mult := 1
|
||||||
|
switch last {
|
||||||
|
case 'k', 'K':
|
||||||
|
num = s[:len(s)-1]
|
||||||
|
case 'M', 'm':
|
||||||
|
num = s[:len(s)-1]
|
||||||
|
mult = 1000
|
||||||
|
default:
|
||||||
|
// already in bps? treat as kbps
|
||||||
|
}
|
||||||
|
v := 0
|
||||||
|
for _, c := range num {
|
||||||
|
if c < '0' || c > '9' {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
v = v*10 + int(c-'0')
|
||||||
|
}
|
||||||
|
if v == 0 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v * mult
|
||||||
|
}
|
||||||
340
internal/engine/stream_source_debrid.go
Normal file
340
internal/engine/stream_source_debrid.go
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
// 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 <video>
|
||||||
|
// 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 <video>
|
||||||
|
// 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
|
||||||
|
}
|
||||||
368
internal/engine/stream_source_debrid_test.go
Normal file
368
internal/engine/stream_source_debrid_test.go
Normal file
|
|
@ -0,0 +1,368 @@
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
90
internal/engine/stream_source_test.go
Normal file
90
internal/engine/stream_source_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseBitrateKbps(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
fb int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"", 5000, 5000},
|
||||||
|
{"192k", 0, 192},
|
||||||
|
{"192K", 0, 192},
|
||||||
|
{"5M", 0, 5000},
|
||||||
|
{"5m", 0, 5000},
|
||||||
|
{"4500", 0, 4500},
|
||||||
|
{"bogus", 100, 100},
|
||||||
|
{"0k", 100, 100},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.in, func(t *testing.T) {
|
||||||
|
if got := parseBitrateKbps(tc.in, tc.fb); got != tc.want {
|
||||||
|
t.Errorf("parseBitrateKbps(%q,%d) = %d, want %d", tc.in, tc.fb, got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEstimateOutputSize(t *testing.T) {
|
||||||
|
if got := estimateOutputSize(nil, TranscodeOpts{}); got != 0 {
|
||||||
|
t.Errorf("nil probe -> 0, got %d", got)
|
||||||
|
}
|
||||||
|
if got := estimateOutputSize(&StreamProbe{}, TranscodeOpts{}); got != 0 {
|
||||||
|
t.Errorf("zero duration -> 0, got %d", got)
|
||||||
|
}
|
||||||
|
probe := &StreamProbe{DurationSec: 60}
|
||||||
|
opts := TranscodeOpts{VideoBitrate: "5M", AudioBitrate: "192k"}
|
||||||
|
// (5000 + 192) * 1000 / 8 = 649_000 bytes/s; *60 = 38_940_000
|
||||||
|
got := estimateOutputSize(probe, opts)
|
||||||
|
if got != 38_940_000 {
|
||||||
|
t.Errorf("estimateOutputSize = %d, want 38_940_000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskFileSourceLifecycle(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
path := filepath.Join(tmp, "movie.bin")
|
||||||
|
data := []byte("hello world")
|
||||||
|
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := newDiskFileSource(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newDiskFileSource: %v", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
if src.Size() != int64(len(data)) {
|
||||||
|
t.Errorf("Size = %d, want %d", src.Size(), len(data))
|
||||||
|
}
|
||||||
|
if src.EstimatedSize() != src.Size() {
|
||||||
|
t.Errorf("EstimatedSize should equal Size for disk source")
|
||||||
|
}
|
||||||
|
if !src.Final() {
|
||||||
|
t.Errorf("disk source should be Final")
|
||||||
|
}
|
||||||
|
if src.Transcoded() {
|
||||||
|
t.Errorf("disk source should not report Transcoded")
|
||||||
|
}
|
||||||
|
if src.FileName() != "movie.bin" {
|
||||||
|
t.Errorf("FileName = %q", src.FileName())
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := make([]byte, 5)
|
||||||
|
n, err := src.ReadAt(buf, 6)
|
||||||
|
if err != nil || n != 5 || string(buf) != "world" {
|
||||||
|
t.Errorf("ReadAt = (%d,%v,%q), want (5,nil,'world')", n, err, buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskFileSourceMissing(t *testing.T) {
|
||||||
|
if _, err := newDiskFileSource("/nonexistent/movie.bin"); err == nil {
|
||||||
|
t.Error("expected error opening nonexistent file")
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue