Compare commits

...

10 commits

Author SHA1 Message Date
Deivid Soto
c4ddd44a1a feat(docker): glibc base with nvenc ffmpeg + par2/7z extractors
Some checks failed
CI / Test (push) Successful in 3m35s
CI / Build (push) Successful in 1m33s
CI / Build-1 (push) Successful in 2m0s
CI / Build-2 (push) Successful in 1m34s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m31s
CI / Coverage (push) Successful in 2m48s
CI / Vet (push) Successful in 2m2s
Alpine/musl can't run NVIDIA's glibc userspace (nvidia-smi, libnvidia-encode,
the static nvenc ffmpeg), so HW transcode was impossible — every 4K/anamorphic
HLS encode fell back to software or failed. Switch the runtime stage to
debian:bookworm-slim + a static BtbN ffmpeg built with nvenc, add par2
(Usenet segment repair) + 7z (RAR/7z extraction), and set
NVIDIA_DRIVER_CAPABILITIES=video,compute,utility so a plain --gpus all (or the
compose device reservation) lights up nvenc with no extra flags. Falls back to
libx264 automatically when no GPU is attached. Build stage cross-compiles
(--platform=BUILDPLATFORM) so multi-arch stays fast; downloads forced over IPv4.
2026-06-01 19:36:41 +02:00
Deivid Soto
8accafbe59 fix(stream): derive H.264 level from frame macroblocks, not height
Anamorphic 2.39:1 scaled to 1080 height = ~2586x1080 = 11016 MBs, busting
level 4.1's 8192-MB MaxFS -> nvenc "InitializeEncoder failed: Invalid Level"
(libx264: "frame MB size > level limit") -> 0 segments, session stalls. Most
4K rips are 2.39:1, so HLS playback was silently broken for them.

H264LevelForFrame(w,h) derives the level from the real macroblock count
(max of MB-tier and height-tier). hls.go computes output width and uses it.
16:9 unchanged; anamorphic bumps to 5.0 when needed. Discovered + verified
during the trickplay smoke.
2026-06-01 19:30:48 +02:00
Deivid Soto
ea00130d08 docs(docker): add docker-compose.yml for one-command setup
Some checks failed
CI / Test (push) Successful in 2m44s
CI / Build (push) Successful in 1m32s
CI / Build-1 (push) Successful in 1m57s
CI / Build-2 (push) Successful in 1m32s
CI / Build-3 (push) Successful in 1m32s
CI / Build-4 (push) Successful in 1m32s
CI / Build-5 (push) Successful in 1m29s
CI / Lint (push) Failing after 2m24s
CI / Coverage (push) Successful in 2m44s
CI / Vet (push) Successful in 1m59s
Rewrite docker-compose.yml with a user-ready setup:
- pull_policy: always — keeps image up-to-date on every `up`
- network_mode: host — required for LAN/Tailscale streaming reach
- UNARR_API_KEY required variable with clear error message
- DOWNLOAD_DIR required variable
- named `unarr-data` volume for piece-DB + HLS cache (keeps them off NFS)
- macOS/Windows bridge + ports alternative in comments
- .env.example alongside with UNARR_API_KEY, DOWNLOAD_DIR, TZ

Quick start: cp .env.example .env && edit .env && docker compose up -d
2026-05-30 09:27:57 +02:00
Deivid Soto
e1fc7b7b6f chore(release): 0.9.19
- Bump version to 0.9.19
- Update CHANGELOG.md
2026-05-30 09:17:38 +02:00
Deivid Soto
75e191f86b fix(docker): three streaming/reliability bugs found in live docker test
funnel: urlPattern matched api.trycloudflare.com before the real quick-tunnel
URL. Cloudflared logs the control-plane endpoint early, so the agent was
advertising a dead URL. Tighten regex to require at least one hyphen — quick
tunnels are always multi-word (e.g. make-appointments-negotiation-blacks).
Covers with funnel_test.go regression test.

download(oneshot): progress reporter called /api/internal/agent/status with a
synthetic "oneshot-<hash>" task ID that is not a UUID, causing the server to
return 400 every 5 s for the entire download. Pass nil client to
NewProgressReporter for one-shot mode; flush/ReportFinal are no-ops when
reporter == nil so terminal output continues unchanged.

torrent: piece-completion SQLite DB (anacrolix) was created inside the download
dir (DataDir). On NFS/SMB mounts SQLite file locking times out, emitting a
warning and falling back to an ephemeral in-memory DB. Add PieceCompletionDir
to TorrentConfig; the daemon now passes config.DataDir() (agent state dir,
always local) so the DB stays off the network mount. One-shot download leaves
the field empty → harmless in-memory fallback as before.
2026-05-30 08:59:33 +02:00
Deivid Soto
16cc0a3033 chore(release): 0.9.18
- Bump version to 0.9.18
- Update CHANGELOG.md
2026-05-30 00:00:12 +02:00
Deivid Soto
efaa3ce59e fix(stream): make completed torrent files readable (mmap creates 0000)
anacrolix mmap storage (storage.NewMMap) creates completed files with
mode 0000. The download succeeds because the agent keeps its own mmap
handle, but any fresh open — direct streaming (/stream :11818), HLS
ffprobe (:11819), or organize-then-reopen — fails with "permission
denied", surfaced in the web UI as "file not found". Both VLC and the
web player were affected.

makeReadable() relaxes the completed file to 0644 (dirs 0755, recursive
for multi-file torrents) right after download finishes, before organize
moves it, so the readable mode survives the rename.
2026-05-29 23:58:09 +02:00
Deivid Soto
02b600dcbc chore(release): 0.9.17
- Bump version to 0.9.17
- Update CHANGELOG.md
2026-05-27 22:05:34 +02:00
Deivid Soto
6270ad41cc fix(hls): drop nvenc -tune ll — kills hls segmentation, bump 0.9.17
With `-tune ll` NVENC emits long IDR-less GOPs that ignore
`-force_key_frames`, so ffmpeg's HLS muxer keeps writing into seg-0.m4s
forever instead of closing it at the 2 s boundary. Result:

* seg-0.m4s balloons to the full encoded size (1.2 GB on a 48-min movie)
* seg-1.m4s never appears
* daemon's pollSegments needs seg-N+1 to confirm seg-N is closed → never
  advances → `mark-ready: timeout` after 60 s
* web player sits on "preparando sesión" until the user gives up

Verified on ffmpeg 6.1.1 + driver 580 / Ryzen 7 7700X + RTX-class GPU:
without `-tune ll`, the same `-preset p3 -rc vbr` cmd produces 39
discrete segments in 15 s at ~27x real-time (was 1 segment / 9 min of
material with `-tune ll` — encoder kept going on a single output).

Introduced by `3b8d77b feat(hls): faster first-start — probe cache +
tighter encoder presets (0.9.9)`. Dropping `-tune ll` costs ~0.5 dB
PSNR at the same bitrate but restores playback. NVENC first-segment
latency remains under 2 s — well within the player's startup budget.
2026-05-27 21:57:16 +02:00
Deivid Soto
7a20ddb4ea feat(scripts): prune Forgejo releases >90 days in ship.sh
Some checks failed
CI / Test (push) Successful in 2m42s
CI / Build (push) Successful in 1m34s
CI / Build-1 (push) Successful in 1m59s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m34s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m29s
CI / Coverage (push) Successful in 2m50s
CI / Vet (push) Successful in 2m6s
Adds step 6 to scripts/ship.sh: after smoke checks, list Forgejo
releases and delete any with created_at older than FORGEJO_PRUNE_DAYS
(default 90). Bounded retention prevents the tc-git CPX11 disk from
filling up (each release ≈ 511MB of attachments × 1/week pace).

Skipped silently with a warn if FORGEJO_TOKEN is not exported, so
the step is opt-in via secret presence (no token = no destructive
action). Tunables: FORGEJO_PRUNE_DAYS, FORGEJO_REPO, FORGEJO_BASE,
SKIP_FORGEJO_PRUNE.
2026-05-27 18:19:08 +02:00
15 changed files with 480 additions and 90 deletions

16
.env.example Normal file
View 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

View file

@ -5,6 +5,36 @@ 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).
## [0.9.19] - 2026-05-30
### Fixed
- **docker**: three streaming/reliability bugs found in live docker test
## [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 ## [0.9.15] - 2026-05-27
@ -28,6 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Other ### Other
- **release**: 0.9.15
- **scripts**: harden release.sh against double-release and inline version bumps - **scripts**: harden release.sh against double-release and inline version bumps
- untrack .claude/ (private local config) - untrack .claude/ (private local config)
## [0.9.14] - 2026-05-27 ## [0.9.14] - 2026-05-27
@ -545,6 +576,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Build ### Build
- add -s -w -trimpath to Makefile, add build-small target with UPX - add -s -w -trimpath to Makefile, add build-small target with UPX
[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.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.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.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13

View file

@ -1,5 +1,8 @@
# ---- 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
@ -13,34 +16,63 @@ 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
# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle / # par2 → repair corrupted Usenet segments (without it a single bad segment
# BtbN static glibc builds — those need a glibc shim on Alpine and the # silently corrupts the output).
# vector-math symbols the GPL builds reference are not satisfiable by # 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads
# gcompat. Alpine ships ffmpeg ~7.x which is fine for the HLS transcoding # RAR5, so unrar — unavailable as a free Debian package — isn't needed).
# pipeline (libx264 + libfdk-aac alternatives included). # tzdata/ca-certificates → TLS + correct local time for schedules/logs.
RUN apk upgrade --no-cache && \ RUN apt-get update && \
apk add --no-cache ca-certificates tzdata ffmpeg wget apt-get install -y --no-install-recommends \
ca-certificates tzdata wget xz-utils par2 p7zip-full && \
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) # Bundle cloudflared so `unarr funnel on` (default: on, see config defaults)
# Just Works on a headless container with no first-run network round-trip. # Just Works on a headless container with no first-run network round-trip.
# TARGETARCH is set automatically by Docker buildx during cross-builds.
ARG TARGETARCH=amd64
RUN case "$TARGETARCH" in \ RUN case "$TARGETARCH" in \
amd64) CF_ARCH=amd64 ;; \ amd64) CF_ARCH=amd64 ;; \
arm64) CF_ARCH=arm64 ;; \ arm64) CF_ARCH=arm64 ;; \
arm) CF_ARCH=armhf ;; \ arm) CF_ARCH=armhf ;; \
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
esac && \ esac && \
wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ wget -4 --tries=3 --timeout=30 -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \
chmod +x /usr/local/bin/cloudflared 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 && \
@ -55,6 +87,13 @@ 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
# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" +
# "compute" capabilities; nvenc needs "video". Baking these here means a plain
# `docker run --gpus all` (or the compose device reservation) lights up HW
# transcode with zero extra flags. Harmless when no GPU is attached.
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility
VOLUME ["/config", "/downloads", "/data"] VOLUME ["/config", "/downloads", "/data"]
ENTRYPOINT ["unarr"] ENTRYPOINT ["unarr"]

View file

@ -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:

View file

@ -265,15 +265,16 @@ func runDaemonStart() error {
// Create torrent downloader // Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: cfg.Download.Dir, DataDir: cfg.Download.Dir,
MetadataTimeout: metaTimeout, PieceCompletionDir: config.DataDir(), // keep piece-completion DB off NFS/SMB mounts
StallTimeout: stallTimeout, MetadataTimeout: metaTimeout,
MaxTimeout: 0, StallTimeout: stallTimeout,
MaxDownloadRate: maxDl, MaxTimeout: 0,
MaxUploadRate: maxUl, MaxDownloadRate: maxDl,
ListenPort: cfg.Download.ListenPort, MaxUploadRate: maxUl,
SeedEnabled: false, ListenPort: cfg.Download.ListenPort,
VPNTunnel: vpnTunnel, SeedEnabled: false,
VPNTunnel: vpnTunnel,
}) })
if err != nil { if err != nil {
return fmt.Errorf("create torrent downloader: %w", err) return fmt.Errorf("create torrent downloader: %w", err)

View file

@ -119,11 +119,10 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
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()

View file

@ -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.9.15" var Version = "0.9.19"

View file

@ -22,6 +22,7 @@ import (
"fmt" "fmt"
"io" "io"
"log" "log"
"math"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@ -1150,10 +1151,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
// helps when the user has set GOMAXPROCS. // helps when the user has set GOMAXPROCS.
args = append(args, "-preset", profile.Preset, "-threads", "0") args = append(args, "-preset", profile.Preset, "-threads", "0")
case "h264_nvenc": case "h264_nvenc":
// p3 + tune=ll trades ~0.3 dB PSNR for 1.5-2× faster encode vs the // p3 + vbr keeps NVENC fast (~1.5 s seg-0) without the segmentation
// previous p4 + tune=hq pair — first-segment encode drops from // breakage `-tune ll` introduced in 0.9.9: with -tune=ll the NVENC
// ~1.5 s to ~0.8 s on RTX-class hardware. // rate control emits long IDR-less GOPs that ignore -force_key_frames,
args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-tune", "ll") // so ffmpeg's HLS muxer never closes seg-0 and the player stalls at
// "preparando sesión" until the 60 s mark-ready timeout. Verified on
// ffmpeg 6.1.1 + driver 580 / RTX-class GPUs: dropping -tune ll
// restores per-segment cuts at 27x real-time vs 28x with -tune ll.
args = append(args, "-preset", profile.Preset, "-rc", "vbr")
case "h264_qsv": case "h264_qsv":
// veryfast is the fastest realistic QSV preset; medium was too // veryfast is the fastest realistic QSV preset; medium was too
// conservative for first-start. look_ahead=0 keeps the encoder // conservative for first-start. look_ahead=0 keeps the encoder
@ -1180,11 +1185,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
// per session start, polluting logs even though encode succeeds. // per session start, polluting logs even though encode succeeds.
args = append(args, "-vaapi_device", "/dev/dri/renderD128") args = append(args, "-vaapi_device", "/dev/dri/renderD128")
} }
// Derive H.264 level from the actual output height. A fixed "4.0" caps the // Derive H.264 level from the actual output FRAME (width × height), not just
// encoder at 1080p — anything taller (1440p, 4K source on quality=original) // height. A fixed "4.0" caps the encoder at 1080p; deriving by height alone
// fails libx264 with "frame MB size > level limit" and emits unplayable // still under-levels anamorphic content — a 2.39:1 source scaled to 1080
// segments. The output height matches qcap.MaxHeight when the source is // height is ~2586×1080 = 11016 MBs, busting level 4.1's 8192-MB cap, which
// downscaled, otherwise probe.Height (already populated by ffprobe). // fails the encode ("Invalid Level" on nvenc, "frame MB size > level limit"
// on libx264) and stalls the session. The output height matches qcap.MaxHeight
// when the source is downscaled, otherwise probe.Height; the output width is
// the source width scaled by the same factor (the filter chain preserves AR).
qcap := resolveQualityCap(cfg.Quality) qcap := resolveQualityCap(cfg.Quality)
outputHeight := qcap.MaxHeight outputHeight := qcap.MaxHeight
if outputHeight == 0 { if outputHeight == 0 {
@ -1193,7 +1201,11 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if outputHeight == 0 || (probe.Height > 0 && probe.Height < outputHeight) { if outputHeight == 0 || (probe.Height > 0 && probe.Height < outputHeight) {
outputHeight = probe.Height outputHeight = probe.Height
} }
args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(outputHeight)) outputWidth := probe.Width
if probe.Height > 0 && outputHeight != probe.Height {
outputWidth = int(math.Round(float64(probe.Width) * float64(outputHeight) / float64(probe.Height)))
}
args = append(args, "-profile:v", "main", "-level:v", H264LevelForFrame(outputWidth, outputHeight))
// Bitrate must match the level libx264 actually picks for outputHeight, // Bitrate must match the level libx264 actually picks for outputHeight,
// not the qcap target for the user's requested label. If a user asks for // not the qcap target for the user's requested label. If a user asks for

View file

@ -271,3 +271,60 @@ func H264LevelForHeight(height int) string {
return "6.0" 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
}

View file

@ -81,12 +81,12 @@ func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) {
configured string configured string
wantPreset string wantPreset string
}{ }{
{HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours {HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours
{HWAccelNone, "medium", "medium"}, // libx264 honours {HWAccelNone, "medium", "medium"}, // libx264 honours
{HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3 {HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3
{HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab {HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab
{HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast {HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast
{HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset {HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset
} }
for _, tc := range cases { for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured) got := ResolveEncoderProfile(tc.hw, tc.configured)
@ -154,3 +154,33 @@ func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) {
} }
} }
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)
}
})
}
}

View file

@ -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)

View file

@ -61,7 +61,12 @@ var defaultTrackers = []string{
// TorrentConfig holds settings for the BitTorrent downloader. // TorrentConfig holds settings for the BitTorrent downloader.
type TorrentConfig struct { type TorrentConfig struct {
DataDir string DataDir string
// PieceCompletionDir, when non-empty, stores the piece-completion SQLite DB
// in this directory instead of DataDir. Use the agent's local state dir
// (not the download dir) so the DB never lands on NFS/SMB volumes where
// SQLite locking times out.
PieceCompletionDir string
MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited) MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited)
StallTimeout time.Duration // no progress during download for this long = stall (default 10m) StallTimeout time.Duration // no progress during download for this long = stall (default 10m)
MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited) MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited)
@ -113,7 +118,23 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// Storage: mmap instead of default file backend. // Storage: mmap instead of default file backend.
// The library author notes file storage has "very high system overhead". // The library author notes file storage has "very high system overhead".
// mmap improves I/O throughput and piece verification speed significantly. // mmap improves I/O throughput and piece verification speed significantly.
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir) //
// When PieceCompletionDir is set (daemon always passes the agent state dir),
// keep the piece-completion SQLite DB off the download dir so it never lands
// on NFS/SMB where SQLite's file locking times out and emits a warning.
if cfg.PieceCompletionDir != "" {
if mkErr := os.MkdirAll(cfg.PieceCompletionDir, 0o755); mkErr != nil {
log.Printf("[torrent] piece-completion dir create failed (%v), DB stays in download dir", mkErr)
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
} else if pc, pcErr := storage.NewDefaultPieceCompletionForDir(cfg.PieceCompletionDir); pcErr != nil {
log.Printf("[torrent] piece-completion db in %q failed (%v), falling back to download dir", cfg.PieceCompletionDir, pcErr)
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
} else {
tcfg.DefaultStorage = storage.NewMMapWithCompletion(cfg.DataDir, pc)
}
} else {
tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir)
}
// Fixed port for incoming peer connections (enables UPnP port mapping). // Fixed port for incoming peer connections (enables UPnP port mapping).
// With ListenPort=0, only ~30% of peers can connect to us. // With ListenPort=0, only ~30% of peers can connect to us.
@ -352,6 +373,13 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir
result.Method = MethodTorrent result.Method = MethodTorrent
result.Size = totalBytes result.Size = totalBytes
// anacrolix mmap storage (storage.NewMMap) creates completed files with mode
// 0000 — the running process keeps its own mmap handle so the download works,
// but any fresh open (streaming, ffprobe/HLS, organize-then-reopen) hits
// "permission denied". Relax perms now, before organize moves the file, so the
// readable mode is preserved through the rename.
makeReadable(filePath)
// If seeding enabled, keep alive (don't cleanup). // If seeding enabled, keep alive (don't cleanup).
// The manager handles seeding lifecycle. // The manager handles seeding lifecycle.
if !d.cfg.SeedEnabled { if !d.cfg.SeedEnabled {
@ -459,6 +487,41 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent
} }
} }
// makeReadable relaxes permissions on a completed download so it can be
// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates
// files with mode 0000; we set files to 0644 and directories to 0755. Errors
// are logged but non-fatal (e.g. NFS root_squash) — the file may still be
// readable depending on the export.
func makeReadable(path string) {
info, err := os.Stat(path)
if err != nil {
log.Printf("[organize] makeReadable stat %q: %v", path, err)
return
}
if !info.IsDir() {
if err := os.Chmod(path, 0o644); err != nil {
log.Printf("[organize] makeReadable chmod %q: %v", path, err)
}
return
}
err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return nil // skip unreadable entries, keep going
}
mode := os.FileMode(0o644)
if d.IsDir() {
mode = 0o755
}
if err := os.Chmod(p, mode); err != nil {
log.Printf("[organize] makeReadable chmod %q: %v", p, err)
}
return nil
})
if err != nil {
log.Printf("[organize] makeReadable walk %q: %v", path, err)
}
}
// Pause drops the torrent handle but keeps partial files on disk for resume. // Pause drops the torrent handle but keeps partial files on disk for resume.
func (d *TorrentDownloader) Pause(taskID string) error { func (d *TorrentDownloader) Pause(taskID string) error {
d.activeMu.Lock() d.activeMu.Lock()

View file

@ -32,9 +32,13 @@ import (
) )
// urlPattern matches the `https://<random>.trycloudflare.com` URL cloudflared // urlPattern matches the `https://<random>.trycloudflare.com` URL cloudflared
// prints when a Quick Tunnel is registered. The hostname has a random // prints when a Quick Tunnel is registered. Quick Tunnel hostnames are always
// hyphen-separated label followed by .trycloudflare.com. // several hyphen-joined dictionary words (e.g.
var urlPattern = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) // `make-appointments-negotiation-blacks`), so we require at least one hyphen.
// This deliberately excludes cloudflared's control-plane endpoint
// `https://api.trycloudflare.com`, which appears earlier in the log stream — a
// permissive `[a-z0-9-]+` matched `api` first and we advertised a dead URL.
var urlPattern = regexp.MustCompile(`https://[a-z0-9]+(?:-[a-z0-9]+)+\.trycloudflare\.com`)
// Config controls how the tunnel is launched. // Config controls how the tunnel is launched.
type Config struct { type Config struct {

View file

@ -0,0 +1,40 @@
package funnel
import "testing"
func TestURLPattern(t *testing.T) {
cases := []struct {
name string
line string
want string
}{
{
name: "real quick tunnel banner",
line: "2026-05-29T22:18:33Z INF | https://make-appointments-negotiation-blacks.trycloudflare.com |",
want: "https://make-appointments-negotiation-blacks.trycloudflare.com",
},
{
name: "two-word hostname",
line: "https://blue-river.trycloudflare.com is ready",
want: "https://blue-river.trycloudflare.com",
},
{
name: "control-plane api endpoint is ignored",
line: `2026-05-29T22:17:59Z DBG POST https://api.trycloudflare.com/tunnel`,
want: "",
},
{
name: "no trycloudflare url",
line: "2026-05-29T22:17:44Z INF Requesting new quick Tunnel on trycloudflare.com...",
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := urlPattern.FindString(tc.line); got != tc.want {
t.Fatalf("FindString(%q) = %q, want %q", tc.line, got, tc.want)
}
})
}
}

View file

@ -17,7 +17,8 @@
# 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh # 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh
# 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub # 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub
# 5. Smoke checks (torrentclaw.com/version + docker run image version) # 5. Smoke checks (torrentclaw.com/version + docker run image version)
# 6. Optional `git push --follow-tags` # 6. Prune Forgejo releases older than FORGEJO_PRUNE_DAYS (default 90)
# 7. Optional `git push --follow-tags`
# #
# Usage: # Usage:
# scripts/ship.sh Detect version from internal/cmd/version.go # scripts/ship.sh Detect version from internal/cmd/version.go
@ -33,6 +34,10 @@
# SKIP_DOCKER=1 skip Docker build/push # SKIP_DOCKER=1 skip Docker build/push
# SKIP_HETZNER=1 skip Hetzner publish # SKIP_HETZNER=1 skip Hetzner publish
# SKIP_SMOKE=1 skip smoke checks # SKIP_SMOKE=1 skip smoke checks
# SKIP_FORGEJO_PRUNE=1 skip Forgejo retention prune
# FORGEJO_TOKEN PAT with write:repository for prune (no token = skip + warn)
# FORGEJO_PRUNE_DAYS retention window, default 90 days
# FORGEJO_REPO default torrentclaw/unarr
# #
set -euo pipefail set -euo pipefail
@ -44,6 +49,10 @@ PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-c
SKIP_DOCKER="${SKIP_DOCKER:-0}" SKIP_DOCKER="${SKIP_DOCKER:-0}"
SKIP_HETZNER="${SKIP_HETZNER:-0}" SKIP_HETZNER="${SKIP_HETZNER:-0}"
SKIP_SMOKE="${SKIP_SMOKE:-0}" SKIP_SMOKE="${SKIP_SMOKE:-0}"
SKIP_FORGEJO_PRUNE="${SKIP_FORGEJO_PRUNE:-0}"
FORGEJO_PRUNE_DAYS="${FORGEJO_PRUNE_DAYS:-90}"
FORGEJO_REPO="${FORGEJO_REPO:-torrentclaw/unarr}"
FORGEJO_BASE="${FORGEJO_BASE:-https://git.torrentclaw.com}"
DRY_RUN=false DRY_RUN=false
PUSH_TAG=false PUSH_TAG=false
@ -161,7 +170,48 @@ if [ "$SKIP_SMOKE" != "1" ]; then
fi fi
fi fi
# 5. Optional push # 6. Forgejo retention prune
if [ "$SKIP_FORGEJO_PRUNE" != "1" ]; then
if [ -z "${FORGEJO_TOKEN:-}" ]; then
warn "FORGEJO_TOKEN not set — skipping Forgejo prune (set it to enable >${FORGEJO_PRUNE_DAYS}-day cleanup)"
else
info "pruning Forgejo releases older than $FORGEJO_PRUNE_DAYS days"
FORGEJO_API="$FORGEJO_BASE/api/v1/repos/$FORGEJO_REPO/releases"
RELEASES_JSON="$(curl -fsSL -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API?limit=50" || echo '[]')"
PRUNE_IDS="$(echo "$RELEASES_JSON" | python3 -c "
import json, sys
from datetime import datetime, timedelta, timezone
days = int('${FORGEJO_PRUNE_DAYS}')
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
for r in json.load(sys.stdin):
created = datetime.fromisoformat(r['created_at'].replace('Z', '+00:00'))
if created < cutoff:
print(f\"{r['id']}\t{r['tag_name']}\t{r['created_at']}\")
" 2>/dev/null || true)"
DELETED=0
FAILED=0
if [ -n "$PRUNE_IDS" ]; then
while IFS=$'\t' read -r REL_ID REL_TAG REL_CREATED; do
[ -z "$REL_ID" ] && continue
CODE="$(curl -s -o /dev/null -w '%{http_code}' -X DELETE -H "Authorization: token $FORGEJO_TOKEN" "$FORGEJO_API/$REL_ID")"
if [ "$CODE" = "204" ]; then
echo " deleted $REL_TAG (created $REL_CREATED)"
DELETED=$((DELETED + 1))
else
warn " failed to delete $REL_TAG (id=$REL_ID, http=$CODE)"
FAILED=$((FAILED + 1))
fi
done <<< "$PRUNE_IDS"
fi
if [ "$FAILED" -gt 0 ]; then
warn "Forgejo prune: $DELETED removed, $FAILED failed"
else
ok "Forgejo prune: $DELETED release(s) removed (>${FORGEJO_PRUNE_DAYS} days old)"
fi
fi
fi
# 7. Optional push
if [ "$PUSH_TAG" = true ]; then if [ "$PUSH_TAG" = true ]; then
info "git push origin main --follow-tags" info "git push origin main --follow-tags"
git push origin main --follow-tags git push origin main --follow-tags