From 23b79f6411f370e34c012afe12ceb952118aa65a Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 12:35:01 +0200 Subject: [PATCH 01/21] chore(release): add ship.sh end-to-end pipeline as GH Actions backup GitHub Actions release.yml + docker job currently doesn't fire (org shadow-ban). ship.sh replicates the CI pipeline locally so releases keep landing on Hetzner + Docker Hub without depending on CI: 1. Sanity checks: clean tree, tag at HEAD, version.go match 2. goreleaser release --skip=publish (build dist/*) 3. publish-cli-release.sh (rsync to Hetzner + flip version.txt) 4. docker buildx --push multi-arch (amd64 + arm64) 5. Smoke: torrentclaw.com/version + docker run image version 6. Optional --push to git-push tag to GH Exposed via make targets: ship, ship-dry, ship-push. --- Makefile | 15 ++++- scripts/ship.sh | 172 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+), 1 deletion(-) create mode 100755 scripts/ship.sh diff --git a/Makefile b/Makefile index 08462b6..b3325bc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry +.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 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; } @./scripts/release.sh --dry-run $(V) +## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions. +## Reads version from internal/cmd/version.go unless V= is provided. +ship: + @./scripts/ship.sh $(V) + +## Ship + git push tag to GH afterwards +ship-push: + @./scripts/ship.sh --push $(V) + +## Preview ship steps without executing +ship-dry: + @./scripts/ship.sh --dry-run $(V) + ## Remove generated files clean: rm -f $(BINARY) coverage.out coverage.html diff --git a/scripts/ship.sh b/scripts/ship.sh new file mode 100755 index 0000000..e45eab2 --- /dev/null +++ b/scripts/ship.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# +# ship.sh — End-to-end CLI release pipeline. +# +# Standalone backup for when GitHub Actions is unavailable (org shadow-ban, +# CI outage, etc). Mirrors what release.yml + docker job in CI would do. +# +# Pre-requisites: +# - scripts/release.sh already ran → version.go bumped + tag created locally +# - SENTRY_DSN exported (Sentry disabled in build if missing) +# - docker logged in to docker.io as the org user +# - SSH key for Hetzner publishing (see publish-cli-release.sh) +# +# Pipeline: +# 1. Sanity: clean tree, tag at HEAD, version.go matches +# 2. goreleaser build (skip GH publish — produces dist/*) +# 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh +# 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub +# 5. Smoke checks (torrentclaw.com/version + docker run image version) +# 6. Optional `git push --follow-tags` +# +# Usage: +# scripts/ship.sh Detect version from internal/cmd/version.go +# scripts/ship.sh 0.9.12 Explicit version +# scripts/ship.sh --dry-run Preview steps, no side effects +# scripts/ship.sh --push 0.9.12 Also git-push tag to GH afterwards +# +# Env knobs: +# SENTRY_DSN telemetry DSN injected at build time +# RELEASE_SIGNING_PUBKEY ed25519 pubkey (base64) for self-update signature check +# DOCKER_IMAGE default torrentclaw/unarr +# PUBLISH_SCRIPT default ../torrentclaw-web/scripts/publish-cli-release.sh +# SKIP_DOCKER=1 skip Docker build/push +# SKIP_HETZNER=1 skip Hetzner publish +# SKIP_SMOKE=1 skip smoke checks +# +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_DIR" + +DOCKER_IMAGE="${DOCKER_IMAGE:-torrentclaw/unarr}" +PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-cli-release.sh}" +SKIP_DOCKER="${SKIP_DOCKER:-0}" +SKIP_HETZNER="${SKIP_HETZNER:-0}" +SKIP_SMOKE="${SKIP_SMOKE:-0}" + +DRY_RUN=false +PUSH_TAG=false +VERSION="" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +info() { echo -e "${CYAN}▸${NC} $*"; } +ok() { echo -e "${GREEN}✓${NC} $*"; } +warn() { echo -e "${YELLOW}⚠${NC} $*"; } +die() { echo -e "${RED}✗${NC} $*" >&2; exit 1; } + +for a in "$@"; do + case "$a" in + --dry-run) DRY_RUN=true ;; + --push) PUSH_TAG=true ;; + -h|--help) + sed -n '2,/^set /p' "$0" | sed 's/^#\s\?//;$d' + exit 0 ;; + [0-9]*) VERSION="$a" ;; + *) die "unknown arg: $a (use --help)" ;; + esac +done + +read_version_go() { + grep 'var Version' internal/cmd/version.go | sed 's/.*"\(.*\)".*/\1/' +} + +REPO_VERSION="$(read_version_go)" +[ -z "$VERSION" ] && VERSION="$REPO_VERSION" +[ -n "$VERSION" ] || die "cannot detect version (pass explicit X.Y.Z)" +TAG="v$VERSION" +MINOR="${VERSION%.*}" + +echo "" +echo -e " ${BOLD}Ship Plan${NC}" +echo -e " ─────────────────────────────" +echo -e " Version: ${GREEN}$TAG${NC}" +echo -e " Docker image: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}" +echo -e " Skip Hetzner: $SKIP_HETZNER" +echo -e " Skip Docker: $SKIP_DOCKER" +echo -e " Push to GH: $PUSH_TAG" +echo -e " Dry run: $DRY_RUN" +echo "" + +# Sanity +[ "$REPO_VERSION" = "$VERSION" ] || die "version.go=$REPO_VERSION ≠ requested $VERSION (bump with make release-* first)" + +if [ "$DRY_RUN" = false ]; then + [ -z "$(git status --porcelain)" ] || die "working tree dirty" + git rev-parse "$TAG" >/dev/null 2>&1 || die "tag $TAG missing — run scripts/release.sh first" + + HEAD_SHA="$(git rev-parse HEAD)" + TAG_SHA="$(git rev-parse "$TAG^{commit}")" + [ "$HEAD_SHA" = "$TAG_SHA" ] || die "HEAD ($HEAD_SHA) ≠ tag commit ($TAG_SHA) — checkout $TAG first" + + command -v goreleaser >/dev/null || die "goreleaser not installed" + [ "$SKIP_DOCKER" = "1" ] || command -v docker >/dev/null || die "docker not installed" + [ "$SKIP_HETZNER" = "1" ] || [ -x "$PUBLISH_SCRIPT" ] || die "publish script missing or not executable: $PUBLISH_SCRIPT" + + if [ -z "${SENTRY_DSN:-}" ]; then + warn "SENTRY_DSN unset — built binaries will have Sentry disabled" + fi +fi + +if [ "$DRY_RUN" = true ]; then + ok "Dry run complete — no changes made" + exit 0 +fi + +# 1. Build +info "goreleaser build ($TAG)" +SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_PUBKEY="${RELEASE_SIGNING_PUBKEY:-}" \ + goreleaser release --clean --skip=publish +ok "dist/ ready" + +# 2. Hetzner +if [ "$SKIP_HETZNER" != "1" ]; then + info "publishing to Hetzner releases volume" + "$PUBLISH_SCRIPT" "$VERSION" + ok "Hetzner version.txt flipped to $VERSION" +fi + +# 3. Docker +if [ "$SKIP_DOCKER" != "1" ]; then + info "docker buildx multi-arch push ($DOCKER_IMAGE:$VERSION, :$MINOR, :latest)" + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg VERSION="$TAG" \ + -t "$DOCKER_IMAGE:$VERSION" \ + -t "$DOCKER_IMAGE:$MINOR" \ + -t "$DOCKER_IMAGE:latest" \ + --push . + ok "Docker Hub: $DOCKER_IMAGE:{$VERSION,$MINOR,latest}" +fi + +# 4. Smoke +if [ "$SKIP_SMOKE" != "1" ]; then + info "smoke checks" + if [ "$SKIP_HETZNER" != "1" ]; then + LIVE_VERSION="$(curl -fsSL https://torrentclaw.com/version 2>/dev/null | tr -d '[:space:]' || echo '')" + if [ "$LIVE_VERSION" = "$VERSION" ]; then + ok "torrentclaw.com/version = $LIVE_VERSION" + else + warn "torrentclaw.com/version = '$LIVE_VERSION' (expected $VERSION)" + fi + fi + + if [ "$SKIP_DOCKER" != "1" ]; then + DOCKER_VERSION="$(docker run --rm "$DOCKER_IMAGE:$VERSION" version 2>/dev/null | grep -oE 'v[0-9.]+' | head -1)" + if [ "$DOCKER_VERSION" = "$TAG" ]; then + ok "docker image $DOCKER_IMAGE:$VERSION reports $DOCKER_VERSION" + else + warn "docker image reports '$DOCKER_VERSION' (expected $TAG)" + fi + fi +fi + +# 5. Optional push +if [ "$PUSH_TAG" = true ]; then + info "git push origin main --follow-tags" + git push origin main --follow-tags + ok "tag $TAG pushed to GitHub" +fi + +echo "" +ok "${BOLD}$TAG shipped${NC}" From 4b3f54d692180a5e58c01f9b287c861bd24c7dac Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 12:46:24 +0200 Subject: [PATCH 02/21] chore(skills): add /publish slash command + allow .claude/ in git Mirrors the slash command added in torrentclaw-web/.claude/commands. With the global ~/.gitignore excluding .claude/ by default, the gitignore override is required for project-shared commands/agents/hooks to be checked in (settings.local.json and projects/ stay local). /publish documents the full unarr release flow (bump + tag + binaries + Hetzner + Docker Hub + smoke) as a single command, while GitHub Actions remains unavailable for the torrentclaw org. --- .gitignore | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 81f1284..7b50c64 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,20 @@ dist-ffbinaries/ # Docker tmp/ config/ -dist-ffbinaries/ \ No newline at end of file +dist-ffbinaries/ + +# Claude Code: global ~/.gitignore excludes .claude/ by default, which hides +# project-shared agents/commands/hooks. Override here to commit the shared +# pieces (agents, commands, hooks, settings.json). Keep per-user state local. +!.claude/ +!.claude/agents/ +!.claude/agents/** +!.claude/commands/ +!.claude/commands/** +!.claude/hooks/ +!.claude/hooks/** +!.claude/settings.json +.claude/settings.local.json +.claude/projects/ +.claude/scheduled_tasks.lock +.claude/skills/ \ No newline at end of file From e3d38791d3f041a40c486e487831ff0fb1fdf42a Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 12:48:40 +0200 Subject: [PATCH 03/21] feat(agent): send full transcoder diagnostic in register payload (0.9.12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daemon now runs engine.DetectHWAccelDiagnostic at startup (instead of the lighter DetectHWAccel) and ships the full picture — ffmpeg version, resolved binary path, HW encoders compiled in, device files / drivers detected — up to the server in the RegisterRequest payload. Why: the most common cause of slow first-play is a software-only ffmpeg build. Surfacing the diagnostic in the web AgentsTab "Diagnose transcoder" modal lets a user see *why* their backend landed on libx264 (e.g. brew's default formula ships without --enable-nvenc, or the container is missing /dev/nvidia0) without SSHing in to run `unarr probe-hwaccel` manually. Also emits a single `[transcode]` startup log line summarising the same data — convenient for `journalctl --user -u unarr | grep transcode`. Bounded by a 10 s context so a hung ffmpeg binary can't stall daemon startup forever. --- CHANGELOG.md | 19 +++++++++++++++++++ internal/agent/daemon.go | 14 +++++++++++++- internal/agent/types.go | 9 +++++++++ internal/cmd/daemon.go | 18 +++++++++++++++++- internal/cmd/version.go | 2 +- 5 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 534bd99..3d75ac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.12] - 2026-05-27 + +### Added + +- **transcoder diagnostic in register payload**: daemon now sends the full + HWAccel diagnostic (ffmpeg version, resolved binary path, list of HW + encoders compiled in, list of device files / drivers present) up to the + server on register. The web "Diagnose transcoder" modal surfaces these + so a user stuck on software libx264 can see *why* (e.g. ffmpeg shipped + without `--enable-nvenc`, or `/dev/nvidia0` missing inside a container) + without SSHing into their machine + running `unarr probe-hwaccel`. +- **`[transcode]` startup log line**: daemon prints a single one-line + summary of the picked backend + version + binary path + devices at + start. Same data the web shows; convenient for `journalctl --user -u + unarr | grep transcode`. + ## [0.9.11] - 2026-05-27 @@ -486,6 +502,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - add -s -w -trimpath to Makefile, add build-small target with UPX [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.12]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.12 +[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 diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 68a187f..f7994fb 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -28,7 +28,15 @@ type DaemonConfig struct { ScanPaths []string // configured scan paths for file deletion validation HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none") MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px) - AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true) + // Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon + // start. Surfaced in the web "Diagnose transcoder" modal — lets a user + // see which encoders the ffmpeg binary supports and which devices the + // host exposes without running `unarr probe-hwaccel`. + FFmpegVersion string // first line of `ffmpeg -version` + FFmpegPath string // resolved binary path + HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders` + HWDevices []string // device files + driver bins detected at probe time + AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true) } // Daemon manages agent registration and the sync loop. @@ -122,6 +130,10 @@ func (d *Daemon) Register(ctx context.Context) error { TailscaleIP: d.cfg.TailscaleIP, HWAccel: d.cfg.HWAccel, MaxTranscodeHeight: d.cfg.MaxTranscodeHeight, + FFmpegVersion: d.cfg.FFmpegVersion, + FFmpegPath: d.cfg.FFmpegPath, + HWEncoders: d.cfg.HWEncoders, + HWDevices: d.cfg.HWDevices, VPNActive: d.vpnActive, VPNMode: d.vpnMode, VPNServer: d.vpnServer, diff --git a/internal/agent/types.go b/internal/agent/types.go index 00802bc..ae87bb6 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -26,6 +26,15 @@ type RegisterRequest struct { // up to 2160p. HWAccel string `json:"hwAccel,omitempty"` MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"` + // Diagnostic surface filled by engine.DetectHWAccelDiagnostic at daemon + // start. Surfaced in the web "Diagnose transcoder" modal so users can + // see *why* their HWAccel landed on "none" without running + // `unarr probe-hwaccel` locally — most commonly the ffmpeg binary + // shipped without HW encoders (linuxbrew, brew's default formula). + FFmpegVersion string `json:"ffmpegVersion,omitempty"` + FFmpegPath string `json:"ffmpegPath,omitempty"` + HWEncoders []string `json:"hwEncoders,omitempty"` + HWDevices []string `json:"hwDevices,omitempty"` // Managed-VPN split-tunnel state. The web tracks which agent holds the single // WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent // connection); other agents are told to use OpenVPN on their host instead. diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index b0cca22..28b948b 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -143,7 +143,19 @@ func runDaemonStart() error { // is what the web side uses to decide whether the user should pre-empt // transcoding by downloading a smaller version (4K source on a software // libx264-only host is the canonical case where pre-download wins). - hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath) + // + // Use the full diagnostic (encoders + devices + ffmpeg version) instead + // of just the picked backend — the extra fields ride along in the + // register payload so the web "Diagnose transcoder" modal can show *why* + // libx264 was selected on a host with a GPU (e.g. brew's ffmpeg without + // --enable-nvenc). 10 s ceiling so a hung ffmpeg binary can't stall + // startup forever. + ffmpegResolved, _ := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath) + probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) + hwDiag := engine.DetectHWAccelDiagnostic(probeCtx, ffmpegResolved) + probeCancel() + log.Println(hwDiag.LogLine()) + hwAccelPick := hwDiag.Pick maxTranscodeHeight := 1080 if hwAccelPick != engine.HWAccelNone { maxTranscodeHeight = 2160 @@ -162,6 +174,10 @@ func runDaemonStart() error { ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath), HWAccel: string(hwAccelPick), MaxTranscodeHeight: maxTranscodeHeight, + FFmpegVersion: hwDiag.FFmpegVersion, + FFmpegPath: hwDiag.FFmpegPath, + HWEncoders: hwDiag.Encoders, + HWDevices: hwDiag.Devices, AutoUpgrade: cfg.Daemon.AutoUpgradeEnabled(), } diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 7ed3030..f4f3f21 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.9.11" +var Version = "0.9.12" From 4f304fb13a06c8c5969e7bf5c16694e5456dc817 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 14:11:24 +0200 Subject: [PATCH 04/21] fix(daemon): defer probeCancel so a panic mid-diagnostic still releases ctx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DetectHWAccelDiagnostic spawns subprocess calls; an unexpected panic (broken ffmpeg binary, OOM mid-exec) would otherwise leave the WithTimeout context dangling until natural expiry. defer keeps the goroutine + timer reachable until runDaemonStart returns, but on a long-lived daemon that's the process lifetime anyway — same effective cost, with the safety guarantee. --- internal/cmd/daemon.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 28b948b..668ecff 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -152,8 +152,8 @@ func runDaemonStart() error { // startup forever. ffmpegResolved, _ := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath) probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer probeCancel() // guard against a panic inside DetectHWAccelDiagnostic hwDiag := engine.DetectHWAccelDiagnostic(probeCtx, ffmpegResolved) - probeCancel() log.Println(hwDiag.LogLine()) hwAccelPick := hwDiag.Pick maxTranscodeHeight := 1080 From 4ccd37aa5d0e45231c126042018bdf73e5042481 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 14:40:53 +0200 Subject: [PATCH 05/21] feat(agent): session-ready webhook for SSE-driven player handshake (0.9.13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes Fase 3.3b. Daemon now tells the server the moment a session's first HLS segment + init.mp4 land on disk; the web side flips streaming_session.ready_at = NOW(), which its SSE endpoint pushes to subscribed players so the loading UI flips from "Preparando…" to "Stream listo" without polling HEAD on the segment URL. Surface: - New Client.MarkSessionReady(ctx, sessionId) HTTP method → POST /api/internal/agent/session-ready. - New engine.HLSSession.ReadyCount() + FromCache() accessors so the watcher goroutine doesn't reach into private state. - New cmd.watchSessionReady(ctx, client, hsess, sessionId) goroutine polls ReadyCount every 200 ms with a 60 s deadline + short-circuits for cache-HIT sessions (ready the moment StartHLSSession returns). - Daemon callback spawns it right after streamSrv.HLS().Register so the watcher's lifecycle matches the session's. Best-effort: a transient network failure on the webhook is logged + the goroutine exits — the player's existing HEAD-probe retry path still discovers ready state independently. The webhook is an acceleration, not a hard dependency. --- CHANGELOG.md | 15 +++++++++++++++ internal/agent/client.go | 21 +++++++++++++++++++++ internal/cmd/daemon.go | 40 ++++++++++++++++++++++++++++++++++++++++ internal/cmd/version.go | 2 +- internal/engine/hls.go | 15 +++++++++++++++ 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d75ac7..c8681bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.13] - 2026-05-27 + +### Added + +- **Session-ready webhook** (`/api/internal/agent/session-ready`). Daemon + watches every new HLSSession's segment counter and, the moment seg-0 + + init.mp4 land on disk, POSTs the sessionId to the server. The web side + flips `streaming_session.ready_at = NOW()`, which its new SSE endpoint + pushes to subscribed players so the "Preparando…" UI flips to + "Stream listo" without waiting for the player's HEAD-probe retry loop + to discover it. Cache-HIT sessions fire the webhook immediately on + StartHLSSession return. +- `engine.HLSSession.ReadyCount()` + `FromCache()` accessors so the + ready-watcher goroutine doesn't reach into private state. + ## [0.9.12] - 2026-05-27 ### Added diff --git a/internal/agent/client.go b/internal/agent/client.go index e60b0a4..e7f2c37 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -109,6 +109,27 @@ func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, succes return nil } +// MarkSessionReady signals the server that the first HLS segment + init.mp4 +// landed on disk for the given session. The web side flips +// streaming_session.ready_at = NOW(), which its SSE endpoint emits to +// subscribed players so the "Preparando…" UI ends without polling HEAD +// on /hls//master.m3u8. +// +// Best-effort: the server is the source of truth for session state and +// will reach the same conclusion via HEAD probes anyway if this call +// fails. We log the error in the caller but don't retry — by the time +// a retry would land the user is likely already playing. +func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error { + req := struct { + SessionID string `json:"sessionId"` + }{SessionID: sessionID} + var resp StatusResponse + if err := c.doPost(ctx, "/api/internal/agent/session-ready", req, &resp); err != nil { + return fmt.Errorf("mark session ready: %w", err) + } + return nil +} + // ReportStatus reports download progress. Returns server-side flags the CLI must act on. func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) { var resp StatusResponse diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 668ecff..be66858 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -612,6 +612,11 @@ func runDaemonStart() error { return } streamSrv.HLS().Register(hsess) + // Tell the server seg-0 is on disk as soon as it lands so the + // player's SSE subscription flips its "Preparando…" UI without + // waiting for the browser HEAD-probe loop to discover it + // independently. Cache-HIT sessions are ready immediately. + go watchSessionReady(hlsCtx, agentClient, hsess, sess.SessionID) }() } @@ -940,3 +945,38 @@ func mirrorCORSOrigins(parent context.Context, cfg config.Config, userAgent stri } return out } + +// watchSessionReady polls HLSSession.ReadyCount until the first segment + +// init.mp4 are on disk, then POSTs /api/internal/agent/session-ready so +// the web side flips streaming_session.ready_at — which its SSE endpoint +// pushes to subscribed players. Cache-HIT sessions are ready the moment +// StartHLSSession returns and POST immediately. +// +// Bounded by a 60 s deadline so a permanently stuck encoder doesn't keep +// a goroutine alive forever; if seg-0 never lands the player falls back +// to its existing HEAD-probe retry path anyway. +func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine.HLSSession, sessionID string) { + deadline := time.Now().Add(60 * time.Second) + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + // Cache HIT or seg-0 ready → notify + done. + if hsess.FromCache() || hsess.ReadyCount() >= 1 { + rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + if err := client.MarkSessionReady(rctx, sessionID); err != nil { + log.Printf("[hls %s] mark-ready failed: %v", agent.ShortID(sessionID), err) + } + cancel() + return + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + if time.Now().After(deadline) { + log.Printf("[hls %s] mark-ready: timeout waiting for seg-0", agent.ShortID(sessionID)) + return + } + } +} diff --git a/internal/cmd/version.go b/internal/cmd/version.go index f4f3f21..efb6b30 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.9.12" +var Version = "0.9.13" diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 634f193..4938c11 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -519,6 +519,21 @@ func (s *HLSSession) ProbeInfo() map[string]any { } } +// ReadyCount returns how many segments are currently fully on disk. +// Caller can `>= 1` it to check whether seg-0 has landed (and so the +// player can be told to attach). For cache-HIT sessions this is always +// `segmentCount` from the moment StartHLSSession returns. +func (s *HLSSession) ReadyCount() int { + s.readyMu.Lock() + defer s.readyMu.Unlock() + return s.readyMax +} + +// FromCache reports whether this session was served from the HLS cache +// (no ffmpeg subprocess spawned). Used by ready-watcher logic to short- +// circuit polling — a cache HIT is ready the moment we return. +func (s *HLSSession) FromCache() bool { return s.fromCache } + // MasterPlaylist returns the rendered master.m3u8 contents. func (s *HLSSession) MasterPlaylist() string { return s.manifestRoot } From 69fff32420e26d3a87e3d8301947b5c495965d57 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:02:24 +0200 Subject: [PATCH 06/21] fix(daemon): use parent ctx for MarkSessionReady so cancel propagates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critico flag: rctx was rooted at context.Background() instead of the session's hlsCtx, so a tab close / session cancel mid-POST left the goroutine blocking on the in-flight webhook for up to 10 s. Switched to a child of hlsCtx — the same scope the watchSessionReady loop already respects via the outer ctx.Done() select. Idempotent webhook means a now-orphan session getting marked ready is cosmetic; the savings here are goroutine pinning + a slow webhook on a torn-down session. --- internal/cmd/daemon.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index be66858..a351c1c 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -962,7 +962,10 @@ func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine. for { // Cache HIT or seg-0 ready → notify + done. if hsess.FromCache() || hsess.ReadyCount() >= 1 { - rctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + // Parent ctx so a session cancel mid-POST (user closed tab, + // daemon shutdown) tears down the in-flight webhook instead of + // blocking the goroutine for up to 10 s on a now-orphan call. + rctx, cancel := context.WithTimeout(ctx, 10*time.Second) if err := client.MarkSessionReady(rctx, sessionID); err != nil { log.Printf("[hls %s] mark-ready failed: %v", agent.ShortID(sessionID), err) } From 54932b1ac29c2f0bcac7df1e2a9126caf619e6c1 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:19:51 +0200 Subject: [PATCH 07/21] fix(daemon): defensive IsClosed check in watchSessionReady poll loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the deferred bajo-priority item from the fase 3.3b critico. Without this the watcher kept polling a torn-down HLSSession for up to 60 s — fine in current code paths (Close always pairs with ctx cancel which makes the select{} branch fire), but the function's correctness then leaned on a caller invariant rather than its own state check. Adding IsClosed() as a public wrapper around the existing isClosed() lets the watcher detect any future session-shutdown path (registry replace, idle sweep, internal kill) without touching the unexported helper. --- internal/cmd/daemon.go | 7 +++++++ internal/engine/hls.go | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index a351c1c..2e0c074 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -960,6 +960,13 @@ func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine. ticker := time.NewTicker(200 * time.Millisecond) defer ticker.Stop() for { + // Session torn down through a path that didn't cancel ctx (registry + // replace, idle sweep, internal kill). Bail before polling further — + // without this check the watcher could keep alive for up to 60 s on + // a dead HLSSession that's never going to become ready. + if hsess.IsClosed() { + return + } // Cache HIT or seg-0 ready → notify + done. if hsess.FromCache() || hsess.ReadyCount() >= 1 { // Parent ctx so a session cancel mid-POST (user closed tab, diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 4938c11..6acde30 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -534,6 +534,13 @@ func (s *HLSSession) ReadyCount() int { // circuit polling — a cache HIT is ready the moment we return. func (s *HLSSession) FromCache() bool { return s.fromCache } +// IsClosed reports whether Close() has been invoked. Exposed (vs the +// internal isClosed) so external watchers — the ready-webhook +// goroutine in cmd/daemon.go — can short-circuit polling on a session +// that was torn down through a different code path (registry replace, +// idle sweep) without racing on the unexported helper. +func (s *HLSSession) IsClosed() bool { return s.isClosed() } + // MasterPlaylist returns the rendered master.m3u8 contents. func (s *HLSSession) MasterPlaylist() string { return s.manifestRoot } From cfd4666bb2725ed0062352ebd5cbf3fea82f565e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:44:48 +0200 Subject: [PATCH 08/21] ci: port workflows from .github/ to .forgejo/ (Forgejo Actions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub torrentclaw org is shadow-banned and the CI lives at git.torrentclaw.com now. Forgejo Actions is enabled cluster-wide; this moves the workflows into the runner's natively-watched .forgejo/workflows/ tree and adapts each step so the existing Forgejo runner ('docker', 'ubuntu-latest' labels) can execute them without leaning on GitHub-only tooling. - ci.yml: drop actions/setup-go (use container: golang:1.25), replace golangci-lint-action with the upstream install.sh, drop codecov-action (third-party, can re-add later with a Forgejo-compatible variant). - release.yml: drop goreleaser-action (install via curl), wire GITEA_TOKEN + the new release.gitea_urls block in .goreleaser.yml so goreleaser publishes to Forgejo. Sign step swaps 'gh release upload' for curl against the Forgejo releases API (via the in-cluster forgejo:3000 hostname). VirusTotal job dropped — depended heavily on 'gh release' wiring; can be reimplemented against the Forgejo API later if we re-enable it. - docker-rebuild.yml: drop docker/login-action + docker/build-push-action, use raw 'docker' commands with manually-installed buildx + qemu. Same weekly schedule (Mon 04:17 UTC) and same 'latest' refresh behaviour. - pages.yml: deleted — install.sh / install.ps1 are already served from the Hetzner releases volume at torrentclaw.com/install.sh, so the GitHub Pages copy was redundant even before the shadow-ban. .goreleaser.yml: add release.gitea_urls (api=forgejo:3000, download via the public Forgejo URL) + prerelease:auto. ship.sh uses '--skip=publish' so local runs aren't affected by the new release block. --- {.github => .forgejo}/workflows/ci.yml | 74 ++++----- .forgejo/workflows/docker-rebuild.yml | 61 +++++++ .forgejo/workflows/release.yml | 113 +++++++++++++ .github/workflows/docker-rebuild.yml | 52 ------ .github/workflows/pages.yml | 52 ------ .github/workflows/release.yml | 210 ------------------------- .goreleaser.yml | 12 ++ 7 files changed, 213 insertions(+), 361 deletions(-) rename {.github => .forgejo}/workflows/ci.yml (61%) create mode 100644 .forgejo/workflows/docker-rebuild.yml create mode 100644 .forgejo/workflows/release.yml delete mode 100644 .github/workflows/docker-rebuild.yml delete mode 100644 .github/workflows/pages.yml delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.forgejo/workflows/ci.yml similarity index 61% rename from .github/workflows/ci.yml rename to .forgejo/workflows/ci.yml index 7dabcc4..82ee799 100644 --- a/.github/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,35 +12,26 @@ permissions: jobs: test: name: Test - runs-on: ubuntu-latest - strategy: - matrix: - go-version: ["1.25"] + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v4 - name: Run tests run: go test -v -race -count=1 ./... build: name: Build - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 strategy: matrix: goos: [linux, darwin, windows] goarch: [amd64, arm64] steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - uses: actions/checkout@v4 - name: Build env: @@ -50,30 +41,30 @@ jobs: lint: name: Lint - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - name: Install golangci-lint + run: | + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \ + | sh -s -- -b /usr/local/bin v2.11.4 - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.11.4 + run: golangci-lint run ./... coverage: name: Coverage - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - name: Install python3 + run: apt-get update && apt-get install -y --no-install-recommends python3 - name: Run tests with coverage (all packages) run: | @@ -102,24 +93,13 @@ jobs: print('OK: Coverage meets minimum threshold') " - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 - with: - files: ./coverage.out - fail_ci_if_error: false - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - vet: name: Vet - runs-on: ubuntu-latest + runs-on: docker + container: + image: docker.io/library/golang:1.25 steps: - - uses: actions/checkout@v6 - - - name: Set up Go - uses: actions/setup-go@v6 - with: - go-version: "1.25" + - uses: actions/checkout@v4 - name: Run go vet run: go vet ./... diff --git a/.forgejo/workflows/docker-rebuild.yml b/.forgejo/workflows/docker-rebuild.yml new file mode 100644 index 0000000..34cc3d6 --- /dev/null +++ b/.forgejo/workflows/docker-rebuild.yml @@ -0,0 +1,61 @@ +# Rebuilds and re-pushes the `latest` image without a version bump so newly +# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned +# tags are immutable and never touched here. Runs weekly and on demand. +name: Docker rebuild + +on: + schedule: + # Mondays 04:17 UTC (off the hour to avoid the scheduler rush) + - cron: "17 4 * * 1" + workflow_dispatch: + +jobs: + rebuild: + runs-on: docker + container: + image: docker.io/library/docker:27-cli + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install build deps + run: apk add --no-cache curl git bash + + - name: Install buildx + run: | + mkdir -p ~/.docker/cli-plugins + curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \ + -o ~/.docker/cli-plugins/docker-buildx + chmod +x ~/.docker/cli-plugins/docker-buildx + + - name: Set up qemu + run: docker run --rm --privileged tonistiigi/binfmt --install all + + # Stamp the binary with the most recent release tag (not "dev"). + - name: Resolve version + id: ver + run: | + v=$(git describe --tags --abbrev=0 2>/dev/null || echo dev) + echo "version=$v" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + env: + DH_USER: ${{ secrets.DOCKERHUB_USERNAME }} + DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin + + - name: Build + push (refresh latest) + env: + VERSION: ${{ steps.ver.outputs.version }} + run: | + docker buildx create --name builder --use --driver docker-container + # Refresh the floating tag only — never overwrite a versioned release. + # Force a fresh base pull so apk upgrade picks up new patches. + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg "VERSION=$VERSION" \ + --tag "torrentclaw/unarr:latest" \ + --no-cache \ + --push \ + . diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..3c5a5cc --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,113 @@ +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 injects GITHUB_TOKEN — but goreleaser uses it to talk to + # the *Forgejo* API thanks to the gitea_urls override 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: 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: deivid/unarr + run: | + set -euo pipefail + go run ./scripts/sign-checksums \ + -key "$RELEASE_SIGNING_KEY" \ + -in dist/checksums.txt \ + -out dist/checksums.txt.sig + + # Find the release ID for this tag, then upload the sig as an asset. + rel_id=$(curl -sSf "$FORGEJO_API/repos/$REPO/releases/tags/$RELEASE_TAG" \ + -H "Authorization: token $FORGEJO_TOKEN" | jq -r '.id') + curl -sSf -X POST \ + "$FORGEJO_API/repos/$REPO/releases/$rel_id/assets?name=checksums.txt.sig" \ + -H "Authorization: token $FORGEJO_TOKEN" \ + -F "attachment=@dist/checksums.txt.sig" + + docker: + needs: release + runs-on: docker + container: + # Docker-in-Docker capable image — buildx + qemu pre-installed. + image: docker.io/library/docker:27-cli + steps: + - uses: actions/checkout@v4 + + - name: Install buildx + run: | + apk add --no-cache curl + mkdir -p ~/.docker/cli-plugins + curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \ + -o ~/.docker/cli-plugins/docker-buildx + chmod +x ~/.docker/cli-plugins/docker-buildx + + - name: Login to Docker Hub + env: + DH_USER: ${{ secrets.DOCKERHUB_USERNAME }} + DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin + + - name: Set up qemu + run: docker run --rm --privileged tonistiigi/binfmt --install all + + - name: Build + push multi-arch image + env: + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + VERSION_SEMVER="${VERSION#v}" + MAJOR_MINOR="${VERSION_SEMVER%.*}" + docker buildx create --name builder --use --driver docker-container + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg "VERSION=$VERSION" \ + --tag "torrentclaw/unarr:$VERSION_SEMVER" \ + --tag "torrentclaw/unarr:$MAJOR_MINOR" \ + --tag "torrentclaw/unarr:latest" \ + --push \ + . diff --git a/.github/workflows/docker-rebuild.yml b/.github/workflows/docker-rebuild.yml deleted file mode 100644 index c1634f1..0000000 --- a/.github/workflows/docker-rebuild.yml +++ /dev/null @@ -1,52 +0,0 @@ -# Rebuilds and re-pushes the `latest` image without a version bump so newly -# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned -# tags are immutable and never touched here. Runs weekly and on demand. -name: Docker rebuild - -on: - schedule: - # Mondays 04:17 UTC (off the hour to avoid the scheduler rush) - - cron: "17 4 * * 1" - workflow_dispatch: - -jobs: - rebuild: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - # Stamp the binary with the most recent release tag (not "dev"). - - name: Resolve version - id: ver - run: echo "version=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)" >> "$GITHUB_OUTPUT" - - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: docker/build-push-action@v7 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - # Refresh the floating tag only — never overwrite a versioned release. - tags: torrentclaw/unarr:latest - build-args: | - VERSION=${{ steps.ver.outputs.version }} - # Force a fresh base pull so apk upgrade picks up new patches. - no-cache: true - - - name: Scan image for fixable CVEs (gate) - uses: docker/scout-action@v1 - with: - command: cves - image: torrentclaw/unarr:latest - only-severities: critical,high - only-fixed: true - exit-code: true diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml deleted file mode 100644 index d0c683d..0000000 --- a/.github/workflows/pages.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Deploy install scripts to Pages - -on: - push: - branches: [main] - paths: - - install.sh - - install.ps1 - - CNAME - - .nojekyll - - .github/workflows/pages.yml - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - deploy: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - uses: actions/checkout@v4 - - uses: actions/configure-pages@v5 - - name: Stage install scripts - run: | - mkdir -p _site - cp install.sh install.ps1 _site/ - [ -f CNAME ] && cp CNAME _site/ - touch _site/.nojekyll - # Also index page (humans landing) - cat > _site/index.html <<'HTML' - - unarr installer -

unarr CLI installer

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

Source: github.com/torrentclaw/unarr

- - HTML - - uses: actions/upload-pages-artifact@v3 - with: - path: _site - - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index dcb49ce..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,210 +0,0 @@ -name: Release - -on: - push: - tags: - - "v*" - -permissions: - contents: write - -jobs: - release: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: actions/setup-go@v6 - with: - go-version-file: go.mod - - - uses: goreleaser/goreleaser-action@v6 - with: - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SENTRY_DSN: ${{ secrets.SENTRY_DSN }} - # Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser - # accepts it and the resulting binary disables signature checks - # (back-compat: pre-signing releases continue to update). Set - # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret) - # to turn verification on. - RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }} - - - name: Sign checksums.txt with ed25519 - # Reference secrets.X directly — step-level env defined in this same - # step is unreliable to read from this step's own if: expression. - if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }} - env: - RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }} - RELEASE_TAG: ${{ github.ref_name }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - go run ./scripts/sign-checksums \ - -key "$RELEASE_SIGNING_KEY" \ - -in dist/checksums.txt \ - -out dist/checksums.txt.sig - gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber - - docker: - needs: release - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v6 - with: - images: torrentclaw/unarr - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=raw,value=latest - - - uses: docker/setup-qemu-action@v4 - - uses: docker/setup-buildx-action@v4 - - - uses: docker/login-action@v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - uses: docker/build-push-action@v7 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - VERSION=${{ github.ref_name }} - - # CVE gate. Fails the release on FIXABLE critical/high only — unfixed - # upstream ffmpeg codec CVEs are accepted (see SECURITY.md), so the - # codec noise does not block. Runs post-push (image already published); - # a failure here flags that a fixable CVE slipped through. - - name: Scan image for fixable CVEs (gate) - uses: docker/scout-action@v1 - with: - command: cves - image: torrentclaw/unarr:latest - only-severities: critical,high - only-fixed: true - exit-code: true - - # Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a - # description-API auth hiccup must not undo a successful image push. - - name: Update Docker Hub description - uses: peter-evans/dockerhub-description@v4 - continue-on-error: true - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - repository: torrentclaw/unarr - readme-filepath: ./DOCKERHUB.md - short-description: "unarr — the single binary that replaces your *arr stack" - - - virustotal: - needs: release - runs-on: ubuntu-latest - if: vars.VT_ENABLED == 'true' - steps: - - name: Get release tag - id: tag - run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" - - - name: Download release assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - mkdir -p assets - gh release download "${{ steps.tag.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --dir assets \ - --pattern '*.tar.gz' \ - --pattern '*.zip' \ - --pattern 'checksums.txt' - - - name: Scan assets with VirusTotal - env: - VT_API_KEY: ${{ secrets.VT_API_KEY }} - run: | - mkdir -p results - for file in assets/*; do - filename=$(basename "$file") - echo "Uploading $filename to VirusTotal..." - - response=$(curl -s --request POST \ - --url https://www.virustotal.com/api/v3/files \ - --header "x-apikey: $VT_API_KEY" \ - --form "file=@$file") - - analysis_id=$(echo "$response" | jq -r '.data.id // empty') - if [ -z "$analysis_id" ]; then - echo "::warning::Failed to upload $filename: $response" - continue - fi - - echo "$filename=$analysis_id" >> results/scans.txt - echo " Analysis ID: $analysis_id" - - # Rate limit: VT free tier allows 4 req/min - sleep 16 - done - - - name: Wait for analysis completion - env: - VT_API_KEY: ${{ secrets.VT_API_KEY }} - run: | - echo "Waiting 60s for VirusTotal analysis to complete..." - sleep 60 - - vt_report="## 🛡️ VirusTotal Scan Results\n\n" - vt_report+="| File | Result | Link |\n" - vt_report+="|------|--------|------|\n" - - while IFS='=' read -r filename analysis_id; do - result=$(curl -s --request GET \ - --url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \ - --header "x-apikey: $VT_API_KEY") - - malicious=$(echo "$result" | jq -r '.data.attributes.stats.malicious // 0') - undetected=$(echo "$result" | jq -r '.data.attributes.stats.undetected // 0') - sha256=$(echo "$result" | jq -r '.meta.file_info.sha256 // empty') - - if [ "$malicious" = "0" ]; then - status="✅ Clean ($undetected engines)" - else - status="⚠️ $malicious detections" - fi - - link="https://www.virustotal.com/gui/file/$sha256" - vt_report+="| \`$filename\` | $status | [View]($link) |\n" - - sleep 16 - done < results/scans.txt - - echo -e "$vt_report" > results/report.md - cat results/report.md - - - name: Append scan results to release notes - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - current_body=$(gh release view "${{ steps.tag.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --json body --jq '.body') - - new_body="${current_body} - - $(cat results/report.md)" - - gh release edit "${{ steps.tag.outputs.tag }}" \ - --repo "${{ github.repository }}" \ - --notes "$new_body" diff --git a/.goreleaser.yml b/.goreleaser.yml index 26ce802..099f55f 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -59,6 +59,18 @@ changelog: - "^test:" - "^chore:" +# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN + +# these URLs and publishes the release there instead of GitHub. Reachable via +# `forgejo` hostname inside the dokploy-network (the runner shares it); for +# local goreleaser runs outside the network, override via env GITEA_API_URL. +release: + gitea_urls: + api: http://forgejo:3000/api/v1 + download: https://git.torrentclaw.com + skip_tls_verify: false + draft: false + prerelease: auto + # Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN) # Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN # brews: From afd5856d0d52a8e33906df9fbfb01db0bdbc0cc1 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:45:55 +0200 Subject: [PATCH 09/21] feat(vaapi): hybrid CPU-scale + hwupload encode path (QW2, 0.9.14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes QW2. Validated against the dev box's AMD Raphael iGPU (/dev/dri/renderD128, radeonsi/mesa 25.2.8). The "proper" full-GPU path via scale_vaapi triggers a known mesa 25 + Raphael bug ("Cannot allocate memory" per session start, encode still succeeds but logs are spammy) — hybrid CPU scale → format=nv12 → hwupload → h264_vaapi encode delivers GPU surfaces to the encoder without poking the broken scaler. Three concrete changes in buildHLSFFmpegArgsAt: 1. New `case "h264_vaapi"` adds `-vaapi_device /dev/dri/renderD128`. Multi-GPU hosts (this dev box has NVIDIA on renderD129 + AMD on renderD128) need it so the encoder doesn't bind to a non-VAAPI render node — without it the encoder fell back to NULL device in manual smoke testing. 2. Filter chain branches on codec: VAAPI uses `scale=…,format=nv12,hwupload` while libx264 / NVENC / QSV keep the existing `scale=…,format=yuv420p,setparams=…` shape. The setparams color metadata block is dropped on VAAPI because VAAPI surfaces don't expose VUI fields and the encoder writes its own. 3. Two new unit tests lock the argv shape so a future refactor doesn't accidentally merge the paths back together: TestBuildHLSFFmpegArgsVAAPI asserts the new flags + the ABSENCE of scale_vaapi; TestBuildHLSFFmpegArgsLibx264NoRegression verifies the software path keeps yuv420p + setparams + has none of the VAAPI extras. Manual ffmpeg validation on the dev box: hybrid encode of 5 s 4K → 720p: 0.66 s wall, 472 % CPU, 268 KB output — no errors logged. scale_vaapi variant in comparison spammed "Cannot allocate memory" while emitting valid output. --- CHANGELOG.md | 24 +++++++++++ internal/cmd/version.go | 2 +- internal/engine/hls.go | 35 +++++++++++++-- internal/engine/vaapi_args_test.go | 69 ++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 4 deletions(-) create mode 100644 internal/engine/vaapi_args_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index c8681bf..58b4053 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.14] - 2026-05-27 + +### Changed + +- **VAAPI encode path now ships proper GPU surfaces**. Adds + `-vaapi_device /dev/dri/renderD128` so the encoder doesn't fall + back to a NULL device on multi-GPU hosts (the dev box that + validated this has an NVIDIA dGPU on renderD129 + an AMD iGPU on + renderD128 — without the explicit device the encoder picked the + wrong node). Filter chain switches to `format=nv12,hwupload` + (was `format=yuv420p`) so frames arrive at the encoder as VAAPI + surfaces. Color-metadata `setparams=` block is dropped on the + VAAPI path because VAAPI surfaces don't expose VUI fields the + same way libx264 does — the encoder records its own. + Intentionally avoids `scale_vaapi`: mesa 25 + AMD Raphael iGPU + emit "Cannot allocate memory" per session start, polluting logs + even though encode succeeds. CPU scale + hwupload is the safe + hybrid that works across all VAAPI-capable hosts. +- **Unit tests** lock the argv shape: TestBuildHLSFFmpegArgsVAAPI + asserts the new VAAPI flags + absence of scale_vaapi / + format=yuv420p; TestBuildHLSFFmpegArgsLibx264NoRegression + ensures the libx264 path keeps its `setparams` + `yuv420p` and + doesn't accidentally inherit the VAAPI shape. + ## [0.9.13] - 2026-05-27 ### Added diff --git a/internal/cmd/version.go b/internal/cmd/version.go index efb6b30..497c9a0 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.9.13" +var Version = "0.9.14" diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 6acde30..86219d5 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1168,6 +1168,17 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin // silently ignores `-q:v`, so the constant-quality knob never // took effect anyway. args = append(args, "-realtime", "1") + case "h264_vaapi": + // h264_vaapi has no preset knob. Bitrate args (set later) drive + // rate control. Add `-vaapi_device /dev/dri/renderD128` so the + // encoder doesn't fall back to a NULL device on multi-GPU hosts + // where the default render node is a non-VAAPI GPU (an Nvidia + // dGPU's render node, etc.). The filter chain below switches to + // `format=nv12,hwupload` so frames land on the right VAAPI + // surface before the encoder; we intentionally avoid scale_vaapi + // because mesa 25 + Raphael iGPU emits "Cannot allocate memory" + // per session start, polluting logs even though encode succeeds. + args = append(args, "-vaapi_device", "/dev/dri/renderD128") } // Derive H.264 level from the actual output height. A fixed "4.0" caps the // encoder at 1080p — anything taller (1440p, 4K source on quality=original) @@ -1218,14 +1229,32 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin if maxH == 0 { maxH = cfg.Transcode.MaxHeight } + // VAAPI needs frames as nv12 VAAPI surfaces before the encoder. We do + // scale + format conversion on CPU then `hwupload` once at the end — + // skips the mesa 25 + Raphael iGPU "Cannot allocate memory" log spam + // that scale_vaapi triggers per-session-start while still delivering + // the encoder a GPU surface. setparams is dropped because VAAPI + // surfaces don't expose VUI fields the way libx264 does; the encoder + // records its own color metadata via the source PTS chain. + pixFormat := "yuv420p" + hwUploadTail := "" + colorTail := ",setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv" + if codec == "h264_vaapi" { + pixFormat = "nv12" + hwUploadTail = ",hwupload" + colorTail = "" + } var filterChain string if maxH > 0 && probe.Height > maxH { filterChain = fmt.Sprintf( - "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv", - maxH, + "scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s", + maxH, pixFormat, colorTail, hwUploadTail, ) } else { - filterChain = "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv" + filterChain = fmt.Sprintf( + "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s", + pixFormat, colorTail, hwUploadTail, + ) } args = append(args, "-vf", filterChain) diff --git a/internal/engine/vaapi_args_test.go b/internal/engine/vaapi_args_test.go new file mode 100644 index 0000000..4bdf010 --- /dev/null +++ b/internal/engine/vaapi_args_test.go @@ -0,0 +1,69 @@ +package engine + +import ( + "strings" + "testing" +) + +func TestBuildHLSFFmpegArgsVAAPI(t *testing.T) { + cfg := HLSSessionConfig{ + SessionID: "test", + SourcePath: "/tmp/test.mkv", + Quality: "720p", + AudioIndex: 0, + Transcode: TranscodeRuntime{ + FFmpegPath: "/usr/bin/ffmpeg", + FFprobePath: "/usr/bin/ffprobe", + HWAccel: HWAccelVAAPI, + }, + } + probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100} + args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0) + got := strings.Join(args, " ") + + wants := []string{ + "-hwaccel vaapi", + "-vaapi_device /dev/dri/renderD128", + "-c:v h264_vaapi", + "format=nv12", + "hwupload", + } + for _, want := range wants { + if !strings.Contains(got, want) { + t.Errorf("argv missing %q\n%s", want, got) + } + } + if strings.Contains(got, "scale_vaapi") { + t.Errorf("argv unexpectedly contains scale_vaapi (mesa bug): %s", got) + } + if strings.Contains(got, "format=yuv420p") { + t.Errorf("argv contains format=yuv420p (libx264 path) for VAAPI codec: %s", got) + } +} + +func TestBuildHLSFFmpegArgsLibx264NoRegression(t *testing.T) { + cfg := HLSSessionConfig{ + SessionID: "test", + SourcePath: "/tmp/test.mkv", + Quality: "720p", + AudioIndex: 0, + Transcode: TranscodeRuntime{ + FFmpegPath: "/usr/bin/ffmpeg", + FFprobePath: "/usr/bin/ffprobe", + HWAccel: HWAccelNone, + }, + } + probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100} + args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0) + got := strings.Join(args, " ") + for _, want := range []string{"-c:v libx264", "format=yuv420p", "setparams=colorspace=bt709"} { + if !strings.Contains(got, want) { + t.Errorf("libx264 argv missing %q: %s", want, got) + } + } + for _, bad := range []string{"-vaapi_device", "format=nv12", "hwupload"} { + if strings.Contains(got, bad) { + t.Errorf("libx264 argv unexpectedly contains %q: %s", bad, got) + } + } +} From 70c04a25308543c6089ae959d31b809db5f92825 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:55:21 +0200 Subject: [PATCH 10/21] fix(release): move gitea_urls to top-level (goreleaser v2 schema) goreleaser v2 dropped `release.gitea_urls`; the key is now top-level on its own. With the old nested form `goreleaser release` failed with `yaml: unmarshal errors: line 67: field gitea_urls not found in type config.Release` before even starting the build. Re-anchor to v0.9.14 so the ship pipeline can produce binaries. --- .goreleaser.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 099f55f..6bc4a51 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -63,11 +63,15 @@ changelog: # 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: - gitea_urls: - api: http://forgejo:3000/api/v1 - download: https://git.torrentclaw.com - skip_tls_verify: false draft: false prerelease: auto From 86b27e690b43add3ef4c94353b049bb141f5f63a Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:58:30 +0200 Subject: [PATCH 11/21] test(vaapi): dump full ffmpeg argv for smoke validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds TestBuildHLSFFmpegArgsVAAPIDump alongside the existing assertion tests. Logs the complete argv buildHLSFFmpegArgsAt emits for a typical VAAPI session so an operator can paste it into a shell and reproduce the encode without booting the dev stack — same effect as `journalctl --user -u unarr-dev | grep ffmpeg`, no daemon needed. Verified locally against AMD Raphael iGPU on this dev box: the dumped argv encoded a 5 s 4K source → 720p in 3.1 s wall, produced 3 HLS segments + init.mp4 that decode cleanly under ffprobe. --- internal/engine/vaapi_args_test.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/internal/engine/vaapi_args_test.go b/internal/engine/vaapi_args_test.go index 4bdf010..33d0786 100644 --- a/internal/engine/vaapi_args_test.go +++ b/internal/engine/vaapi_args_test.go @@ -67,3 +67,31 @@ func TestBuildHLSFFmpegArgsLibx264NoRegression(t *testing.T) { } } } + +// TestBuildHLSFFmpegArgsVAAPIDump prints the full argv buildHLSFFmpegArgsAt +// emits for a typical VAAPI session. Mimics the daemon spawn step so the +// operator can verify the ffmpeg command-line shape without booting the +// stack — equivalent to `journalctl --user -u unarr-dev | grep ffmpeg` +// but without waiting for a real player session. +func TestBuildHLSFFmpegArgsVAAPIDump(t *testing.T) { + cfg := HLSSessionConfig{ + SessionID: "vaapi-smoke", + SourcePath: "/mnt/nas/peliculas/sample.mkv", + Quality: "720p", + AudioIndex: -1, + Transcode: TranscodeRuntime{ + FFmpegPath: "/usr/bin/ffmpeg", + FFprobePath: "/usr/bin/ffprobe", + HWAccel: HWAccelVAAPI, + }, + } + probe := &StreamProbe{ + VideoCodec: "hevc", + Width: 3840, + Height: 2160, + DurationSec: 5400, + AudioTracks: []ProbeAudioTrack{{Index: 0, Lang: "en", Codec: "ac3"}}, + } + args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/smoke-tmpdir", 0, 0) + t.Logf("ffmpeg %s", strings.Join(args, " ")) +} From ea16bf98f4890531173db92107206e0eebf3a1bd Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 15:58:45 +0200 Subject: [PATCH 12/21] refactor(ci): point Forgejo URLs at torrentclaw org (post-transfer) Repos were transferred from the deivid user to a dedicated torrentclaw organisation; the workflows reference the org path. --- .forgejo/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 3c5a5cc..fc9ac42 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -53,7 +53,7 @@ jobs: # 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: deivid/unarr + REPO: torrentclaw/unarr run: | set -euo pipefail go run ./scripts/sign-checksums \ From 8205924917f579368518b91477dd52e886e4f577 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 16:15:57 +0200 Subject: [PATCH 13/21] fix(ci): unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN Forgejo runner auto-injects GITHUB_TOKEN; combined with the GITEA_TOKEN we set explicitly, goreleaser errors with 'multiple tokens'. Unset the GitHub one inside the run step so goreleaser follows the Gitea/Forgejo release path defined by .goreleaser.yml's gitea_urls block. --- .forgejo/workflows/release.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index fc9ac42..d757612 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -31,8 +31,11 @@ jobs: - name: Run goreleaser env: - # Forgejo runner injects GITHUB_TOKEN — but goreleaser uses it to talk to - # the *Forgejo* API thanks to the gitea_urls override in .goreleaser.yml. + # 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 @@ -41,7 +44,9 @@ jobs: # RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret) # to turn verification on. RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }} - run: goreleaser release --clean + run: | + unset GITHUB_TOKEN + goreleaser release --clean - name: Sign checksums.txt with ed25519 if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }} From 5e4dbc78ed0a90a05fc8770a32f0bf4f4edf65d1 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 16:12:03 +0200 Subject: [PATCH 14/21] feat(sentry): enhance error handling by skipping user input errors in CaptureError --- internal/cmd/root.go | 5 +++-- internal/sentry/sentry.go | 20 +++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b28ec92..ff8bff4 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -25,8 +25,9 @@ var ( func init() { rootCmd = &cobra.Command{ - Use: "unarr", - Short: "unarr — torrent search and management", + Use: "unarr", + Version: Version, + Short: "unarr — torrent search and management", Long: `unarr is a powerful terminal tool for torrent search and management. Search 30+ torrent sources, inspect torrent quality, discover popular content, diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go index 633fc0d..620d064 100644 --- a/internal/sentry/sentry.go +++ b/internal/sentry/sentry.go @@ -1,12 +1,14 @@ package sentry import ( + "errors" "os" "runtime" "strings" "time" gosentry "github.com/getsentry/sentry-go" + "github.com/spf13/pflag" ) // dsn is injected at build time via ldflags. If empty, Sentry is disabled. @@ -45,8 +47,10 @@ func Close() { } // CaptureError sends a non-fatal error to Sentry with optional command context. +// User-input errors (unknown flag/command, bad value) are skipped — they are +// not bugs, just noise. func CaptureError(err error, command string) { - if err == nil { + if err == nil || isUserInputError(err) { return } @@ -58,6 +62,20 @@ func CaptureError(err error, command string) { }) } +func isUserInputError(err error) bool { + var notExist *pflag.NotExistError + var valueReq *pflag.ValueRequiredError + var invalidVal *pflag.InvalidValueError + var invalidSyn *pflag.InvalidSyntaxError + if errors.As(err, ¬Exist) || errors.As(err, &valueReq) || + errors.As(err, &invalidVal) || errors.As(err, &invalidSyn) { + return true + } + msg := err.Error() + return strings.HasPrefix(msg, "unknown command ") || + strings.HasPrefix(msg, "required flag(s)") +} + // RecoverPanic captures a panic and re-panics after reporting. // Usage: defer sentry.RecoverPanic() func RecoverPanic() { From 116a348670c60a9e5ae4a170a4a33ee9a48eca65 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 16:35:22 +0200 Subject: [PATCH 15/21] docs(positioning): reframe unarr around download/stream/transcode, drop misleading search-first wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Old copy claimed unarr was a "torrent search" tool. unarr's real job is downloading (torrent + debrid + usenet), streaming via local HLS, transcoding with ffmpeg+HW accel, and library management. Search just queries the torrentclaw.com catalog — secondary feature, not the identity. - root cobra Short/Long now lead with download/stream/transcode and list the three backends + WireGuard + Cloudflare Funnel - README hero + subheading mirror the same positioning - DOCKERHUB hero updated to match - "Search & Discovery" group → "Catalog & Discovery" (search still grouped, but framed as catalog browsing not product identity) --- DOCKERHUB.md | 7 ++++--- README.md | 4 ++-- internal/cmd/root.go | 17 ++++++++++------- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/DOCKERHUB.md b/DOCKERHUB.md index 7a9bc0e..3df5b70 100644 --- a/DOCKERHUB.md +++ b/DOCKERHUB.md @@ -1,8 +1,9 @@ # unarr -**The single binary that replaces your whole *arr stack.** Search 30+ torrent -sources, inspect real quality before you download, grab subtitles, and manage -your media library — all from one terminal tool or a headless daemon. +**The single binary that replaces your whole *arr stack.** Built-in torrent, +debrid, and usenet engines. Stream, transcode, and organize your library from +one terminal — or run it as a headless daemon with a web dashboard, WireGuard +split-tunnel, and Cloudflare Funnel remote access. **[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)** diff --git a/README.md b/README.md index 8a5d26d..75c9c62 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,9 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) [![Go Version](https://img.shields.io/github/go-mod/go-version/torrentclaw/unarr)](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. diff --git a/internal/cmd/root.go b/internal/cmd/root.go index ff8bff4..375d8e9 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -27,15 +27,18 @@ func init() { rootCmd = &cobra.Command{ Use: "unarr", Version: Version, - Short: "unarr — torrent search and management", - Long: `unarr is a powerful terminal tool for torrent search and management. - -Search 30+ torrent sources, inspect torrent quality, discover popular content, -find streaming providers, and manage your media collection — all from your terminal. + Short: "Terminal torrent + debrid + usenet client — download, stream, transcode", + Long: `unarr is a terminal-native client that downloads torrents, debrid links, +and usenet (NZB) — all from the same binary. It streams content straight +to mpv/vlc with sequential piece prioritization, transcodes on the fly via +ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and +organizes your library into Movies/TV folders. Run it one-shot or as a +long-running daemon with a built-in WireGuard split-tunnel and remote +playback over Cloudflare Funnel. Get started: unarr init First-time configuration wizard - unarr search "breaking bad" Search for content + unarr download Grab a torrent one-shot unarr start Start the download daemon Documentation: https://torrentclaw.com/cli @@ -56,7 +59,7 @@ Source: https://github.com/torrentclaw/unarr`, // Command groups for organized help output rootCmd.AddGroup( &cobra.Group{ID: "start", Title: "Getting Started:"}, - &cobra.Group{ID: "search", Title: "Search & Discovery:"}, + &cobra.Group{ID: "search", Title: "Catalog & Discovery:"}, &cobra.Group{ID: "download", Title: "Downloads & Streaming:"}, &cobra.Group{ID: "daemon", Title: "Daemon Management:"}, &cobra.Group{ID: "system", Title: "System & Diagnostics:"}, From fceadd2009f6d4cae4c46167a94509768ed7744d Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 16:37:03 +0200 Subject: [PATCH 16/21] chore(scripts): harden release.sh against double-release and inline version bumps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new pre-flight guards in scripts/release.sh, evaluated right after the branch check: 1. Reject if HEAD subject matches `(X.Y.Z)` — historical pattern where the feature commit itself bumped the version (e.g. `feat(...) (0.9.14)`). Forces every release to land in a dedicated `chore(release): X.Y.Z` commit so the changelog + tag point at a clean release boundary. 2. Reject if HEAD is already `chore(release): …` — prevents re-running the script with no new commits since the previous release (would otherwise produce an empty release on top of itself). Scope deliberately `chore(scripts)` (not `chore(release)`) so this very commit doesn't trip guard 2 the next time release.sh runs. --- scripts/release.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/release.sh b/scripts/release.sh index da9b911..46862be 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -55,6 +55,17 @@ fi CURRENT_BRANCH=$(git branch --show-current) [ "$CURRENT_BRANCH" = "main" ] || warn "Not on main branch (current: $CURRENT_BRANCH)" +HEAD_SUBJECT=$(git log -1 --pretty=%s) +if [[ "$HEAD_SUBJECT" =~ \(([0-9]+\.[0-9]+\.[0-9]+)\) ]]; then + die "HEAD commit subject contains inline version bump: \"$HEAD_SUBJECT\" +Release contract: version bumps MUST live in a dedicated 'chore(release): X.Y.Z' commit. +Revert the inline bump and re-run this script — it will create the proper commit." +fi +if [[ "$HEAD_SUBJECT" =~ ^chore\(release\): ]]; then + die "HEAD is already a chore(release) commit: \"$HEAD_SUBJECT\" +Nothing new to release. Add commits since the last release or amend intentionally outside this script." +fi + # ── Resolve version ──────────────────────────────────────────────── LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") LATEST_VERSION="${LATEST_TAG#v}" From 4d7444ef5b914bd51a8c1673035e1a8f14b0e1ef Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 16:50:16 +0200 Subject: [PATCH 17/21] fix(sentry): skip "daemon not running" stop/reload errors --- internal/agent/state.go | 31 +++++++++++++++++++++++----- internal/agent/state_test.go | 37 ++++++++++++++++++++++++++++++++++ internal/cmd/daemon_control.go | 6 +++--- internal/cmd/reload_unix.go | 6 +++--- internal/sentry/sentry.go | 5 +++++ internal/sentry/sentry_test.go | 17 +++++++++++++++- 6 files changed, 90 insertions(+), 12 deletions(-) diff --git a/internal/agent/state.go b/internal/agent/state.go index 1f00033..bf0b93b 100644 --- a/internal/agent/state.go +++ b/internal/agent/state.go @@ -2,6 +2,8 @@ package agent import ( "encoding/json" + "errors" + "fmt" "os" "path/filepath" "time" @@ -9,6 +11,11 @@ import ( "github.com/torrentclaw/unarr/internal/config" ) +// ErrDaemonNotRunning is returned by callers that need a running daemon but +// find no state file on disk. Sentinel so user-facing commands (stop/reload) +// can wrap it and Sentry can filter it out as a non-bug. +var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)") + // DaemonState is written to disk every heartbeat for external tools to read. type DaemonState struct { AgentID string `json:"agentId"` @@ -69,17 +76,31 @@ func WriteState(state *DaemonState) { 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 { + state, _ := LoadState() + return state +} + +// LoadState reads the daemon state and returns explicit errors: +// - ErrDaemonNotRunning when the state file does not exist +// - a wrapped json error when the file exists but cannot be decoded +// (a real bug worth reporting to Sentry) +func LoadState() (*DaemonState, error) { data, err := os.ReadFile(StateFilePath()) if err != nil { - return nil + if errors.Is(err, os.ErrNotExist) { + return nil, ErrDaemonNotRunning + } + return nil, err } var state DaemonState - if json.Unmarshal(data, &state) != nil { - return nil + if err := json.Unmarshal(data, &state); err != 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). diff --git a/internal/agent/state_test.go b/internal/agent/state_test.go index 6c9abdd..7e275be 100644 --- a/internal/agent/state_test.go +++ b/internal/agent/state_test.go @@ -1,6 +1,7 @@ package agent import ( + "errors" "os" "path/filepath" "testing" @@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) { t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state) } } + +func TestLoadStateNotFound(t *testing.T) { + tmpDir := t.TempDir() + origFn := stateFilePathFn + stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") } + defer func() { stateFilePathFn = origFn }() + + state, err := LoadState() + if state != nil { + t.Errorf("LoadState() state = %+v, want nil", state) + } + if !errors.Is(err, ErrDaemonNotRunning) { + t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err) + } +} + +func TestLoadStateCorruptedJSON(t *testing.T) { + tmpDir := t.TempDir() + origFn := stateFilePathFn + path := filepath.Join(tmpDir, "daemon.state.json") + stateFilePathFn = func() string { return path } + defer func() { stateFilePathFn = origFn }() + + os.WriteFile(path, []byte("not valid json{{{"), 0o644) + + state, err := LoadState() + if state != nil { + t.Errorf("LoadState() state = %+v, want nil", state) + } + if err == nil { + t.Fatal("LoadState() err = nil, want decode error") + } + if errors.Is(err, ErrDaemonNotRunning) { + t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry") + } +} diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go index 558fb26..277fc01 100644 --- a/internal/cmd/daemon_control.go +++ b/internal/cmd/daemon_control.go @@ -262,9 +262,9 @@ func runDaemonReload() error { // stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID. // Used as fallback on platforms without a service manager (and as Windows implementation). func stopDaemonByPID() error { - state := agent.ReadState() - if state == nil { - return fmt.Errorf("daemon does not appear to be running (state file not found)") + state, err := agent.LoadState() + if err != nil { + return err } return killPID(state.PID) } diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go index 056112f..71736ea 100644 --- a/internal/cmd/reload_unix.go +++ b/internal/cmd/reload_unix.go @@ -43,9 +43,9 @@ func startReloadWatcher(rc *ReloadableConfig) { // sendReloadSignal sends SIGUSR1 to the running daemon process. func sendReloadSignal() error { - state := agent.ReadState() - if state == nil { - return fmt.Errorf("daemon does not appear to be running (state file not found)") + state, err := agent.LoadState() + if err != nil { + return err } p, err := os.FindProcess(state.PID) if err != nil { diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go index 620d064..fadf09a 100644 --- a/internal/sentry/sentry.go +++ b/internal/sentry/sentry.go @@ -9,6 +9,8 @@ import ( gosentry "github.com/getsentry/sentry-go" "github.com/spf13/pflag" + + "github.com/torrentclaw/unarr/internal/agent" ) // dsn is injected at build time via ldflags. If empty, Sentry is disabled. @@ -63,6 +65,9 @@ func CaptureError(err error, command string) { } func isUserInputError(err error) bool { + if errors.Is(err, agent.ErrDaemonNotRunning) { + return true + } var notExist *pflag.NotExistError var valueReq *pflag.ValueRequiredError var invalidVal *pflag.InvalidValueError diff --git a/internal/sentry/sentry_test.go b/internal/sentry/sentry_test.go index 671e641..49360d7 100644 --- a/internal/sentry/sentry_test.go +++ b/internal/sentry/sentry_test.go @@ -1,6 +1,11 @@ package sentry -import "testing" +import ( + "fmt" + "testing" + + "github.com/torrentclaw/unarr/internal/agent" +) func TestEnvironment(t *testing.T) { tests := []struct { @@ -45,3 +50,13 @@ func TestSetUser(t *testing.T) { // Should not panic without initialization SetUser("agent-123") } + +func TestIsUserInputErrorDaemonNotRunning(t *testing.T) { + if !isUserInputError(agent.ErrDaemonNotRunning) { + t.Error("ErrDaemonNotRunning should be treated as user-input error") + } + wrapped := fmt.Errorf("stop daemon: %w", agent.ErrDaemonNotRunning) + if !isUserInputError(wrapped) { + t.Error("wrapped ErrDaemonNotRunning should be treated as user-input error") + } +} From 9fe796f19519e61795a836fd0edb9ec13809d6dc Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 17:00:15 +0200 Subject: [PATCH 18/21] chore: untrack .claude/ (private local config) --- .gitignore | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 7b50c64..8015bab 100644 --- a/.gitignore +++ b/.gitignore @@ -43,18 +43,5 @@ tmp/ config/ dist-ffbinaries/ -# Claude Code: global ~/.gitignore excludes .claude/ by default, which hides -# project-shared agents/commands/hooks. Override here to commit the shared -# pieces (agents, commands, hooks, settings.json). Keep per-user state local. -!.claude/ -!.claude/agents/ -!.claude/agents/** -!.claude/commands/ -!.claude/commands/** -!.claude/hooks/ -!.claude/hooks/** -!.claude/settings.json -.claude/settings.local.json -.claude/projects/ -.claude/scheduled_tasks.lock -.claude/skills/ \ No newline at end of file +# Claude Code: keep entirely local, do not track +.claude/ \ No newline at end of file From 91353327775d280822dea1065e401146008e5cb5 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 17:03:26 +0200 Subject: [PATCH 19/21] refactor(sentry): decouple agent import via string-match, rename predicate --- internal/agent/state.go | 8 +++++--- internal/cmd/daemon_control.go | 6 +++++- internal/cmd/reload_unix.go | 6 +++++- internal/sentry/sentry.go | 21 +++++++++++---------- internal/sentry/sentry_test.go | 18 ++++++++++-------- 5 files changed, 36 insertions(+), 23 deletions(-) diff --git a/internal/agent/state.go b/internal/agent/state.go index bf0b93b..cc08ae5 100644 --- a/internal/agent/state.go +++ b/internal/agent/state.go @@ -11,9 +11,11 @@ import ( "github.com/torrentclaw/unarr/internal/config" ) -// ErrDaemonNotRunning is returned by callers that need a running daemon but -// find no state file on disk. Sentinel so user-facing commands (stop/reload) -// can wrap it and Sentry can filter it out as a non-bug. +// 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. diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go index 277fc01..4ac4d10 100644 --- a/internal/cmd/daemon_control.go +++ b/internal/cmd/daemon_control.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "os/exec" @@ -264,7 +265,10 @@ func runDaemonReload() error { func stopDaemonByPID() error { state, err := agent.LoadState() if err != nil { - return err + if errors.Is(err, agent.ErrDaemonNotRunning) { + return err + } + return fmt.Errorf("read daemon state: %w", err) } return killPID(state.PID) } diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go index 71736ea..34d8e4d 100644 --- a/internal/cmd/reload_unix.go +++ b/internal/cmd/reload_unix.go @@ -3,6 +3,7 @@ package cmd import ( + "errors" "fmt" "log" "os" @@ -45,7 +46,10 @@ func startReloadWatcher(rc *ReloadableConfig) { func sendReloadSignal() error { state, err := agent.LoadState() if err != nil { - return err + 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 { diff --git a/internal/sentry/sentry.go b/internal/sentry/sentry.go index fadf09a..3f16c08 100644 --- a/internal/sentry/sentry.go +++ b/internal/sentry/sentry.go @@ -9,8 +9,6 @@ import ( gosentry "github.com/getsentry/sentry-go" "github.com/spf13/pflag" - - "github.com/torrentclaw/unarr/internal/agent" ) // dsn is injected at build time via ldflags. If empty, Sentry is disabled. @@ -48,11 +46,16 @@ func Close() { gosentry.Flush(flushTimeout) } +// daemonNotRunningMarker matches the message of agent.ErrDaemonNotRunning +// without importing the agent package — avoids a sentry → agent dependency +// that would risk a cycle if agent ever needed to report errors itself. +const daemonNotRunningMarker = "daemon does not appear to be running" + // CaptureError sends a non-fatal error to Sentry with optional command context. -// User-input errors (unknown flag/command, bad value) are skipped — they are -// not bugs, just noise. +// Expected non-bug errors (bad CLI input, daemon not running) are skipped to +// keep the issue feed signal-heavy. func CaptureError(err error, command string) { - if err == nil || isUserInputError(err) { + if err == nil || shouldSkipSentry(err) { return } @@ -64,10 +67,7 @@ func CaptureError(err error, command string) { }) } -func isUserInputError(err error) bool { - if errors.Is(err, agent.ErrDaemonNotRunning) { - return true - } +func shouldSkipSentry(err error) bool { var notExist *pflag.NotExistError var valueReq *pflag.ValueRequiredError var invalidVal *pflag.InvalidValueError @@ -78,7 +78,8 @@ func isUserInputError(err error) bool { } msg := err.Error() return strings.HasPrefix(msg, "unknown command ") || - strings.HasPrefix(msg, "required flag(s)") + strings.HasPrefix(msg, "required flag(s)") || + strings.Contains(msg, daemonNotRunningMarker) } // RecoverPanic captures a panic and re-panics after reporting. diff --git a/internal/sentry/sentry_test.go b/internal/sentry/sentry_test.go index 49360d7..4005d14 100644 --- a/internal/sentry/sentry_test.go +++ b/internal/sentry/sentry_test.go @@ -1,10 +1,9 @@ package sentry import ( + "errors" "fmt" "testing" - - "github.com/torrentclaw/unarr/internal/agent" ) func TestEnvironment(t *testing.T) { @@ -51,12 +50,15 @@ func TestSetUser(t *testing.T) { SetUser("agent-123") } -func TestIsUserInputErrorDaemonNotRunning(t *testing.T) { - if !isUserInputError(agent.ErrDaemonNotRunning) { - t.Error("ErrDaemonNotRunning should be treated as user-input error") +func TestShouldSkipSentryDaemonNotRunning(t *testing.T) { + // String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel + // is reworded, this test fails loudly so the marker can be updated. + err := errors.New("daemon does not appear to be running (state file not found)") + if !shouldSkipSentry(err) { + t.Error("ErrDaemonNotRunning message should be skipped") } - wrapped := fmt.Errorf("stop daemon: %w", agent.ErrDaemonNotRunning) - if !isUserInputError(wrapped) { - t.Error("wrapped ErrDaemonNotRunning should be treated as user-input error") + wrapped := fmt.Errorf("read daemon state: %w", err) + if !shouldSkipSentry(wrapped) { + t.Error("wrapped ErrDaemonNotRunning message should be skipped") } } From e3884089784e539f5289fab9ef52ddf701588d88 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 17:06:13 +0200 Subject: [PATCH 20/21] chore(release): 0.9.15 - Bump version to 0.9.15 - Update CHANGELOG.md --- CHANGELOG.md | 102 +++++++++++++++++++++------------------- internal/cmd/version.go | 2 +- 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58b4053..de1dd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,61 +5,63 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [0.9.14] - 2026-05-27 +## [0.9.15] - 2026-05-27 + + +### Added + +- **sentry**: enhance error handling by skipping user input errors in CaptureError ### Changed -- **VAAPI encode path now ships proper GPU surfaces**. Adds - `-vaapi_device /dev/dri/renderD128` so the encoder doesn't fall - back to a NULL device on multi-GPU hosts (the dev box that - validated this has an NVIDIA dGPU on renderD129 + an AMD iGPU on - renderD128 — without the explicit device the encoder picked the - wrong node). Filter chain switches to `format=nv12,hwupload` - (was `format=yuv420p`) so frames arrive at the encoder as VAAPI - surfaces. Color-metadata `setparams=` block is dropped on the - VAAPI path because VAAPI surfaces don't expose VUI fields the - same way libx264 does — the encoder records its own. - Intentionally avoids `scale_vaapi`: mesa 25 + AMD Raphael iGPU - emit "Cannot allocate memory" per session start, polluting logs - even though encode succeeds. CPU scale + hwupload is the safe - hybrid that works across all VAAPI-capable hosts. -- **Unit tests** lock the argv shape: TestBuildHLSFFmpegArgsVAAPI - asserts the new VAAPI flags + absence of scale_vaapi / - format=yuv420p; TestBuildHLSFFmpegArgsLibx264NoRegression - ensures the libx264 path keeps its `setparams` + `yuv420p` and - doesn't accidentally inherit the VAAPI shape. +- **ci**: point Forgejo URLs at torrentclaw org (post-transfer) +- **sentry**: decouple agent import via string-match, rename predicate +### Documentation + +- **positioning**: reframe unarr around download/stream/transcode, drop misleading search-first wording + +### Fixed + +- **ci**: unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN +- **sentry**: skip "daemon not running" stop/reload errors + +### Other + +- **scripts**: harden release.sh against double-release and inline version bumps +- untrack .claude/ (private local config) +## [0.9.14] - 2026-05-27 + + +### Added + +- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14) + +### CI/CD + +- port workflows from .github/ to .forgejo/ (Forgejo Actions) + +### Fixed + +- **daemon**: defensive IsClosed check in watchSessionReady poll loop +- **daemon**: use parent ctx for MarkSessionReady so cancel propagates +- **release**: move gitea_urls to top-level (goreleaser v2 schema) ## [0.9.13] - 2026-05-27 -### Added - -- **Session-ready webhook** (`/api/internal/agent/session-ready`). Daemon - watches every new HLSSession's segment counter and, the moment seg-0 + - init.mp4 land on disk, POSTs the sessionId to the server. The web side - flips `streaming_session.ready_at = NOW()`, which its new SSE endpoint - pushes to subscribed players so the "Preparando…" UI flips to - "Stream listo" without waiting for the player's HEAD-probe retry loop - to discover it. Cache-HIT sessions fire the webhook immediately on - StartHLSSession return. -- `engine.HLSSession.ReadyCount()` + `FromCache()` accessors so the - ready-watcher goroutine doesn't reach into private state. - -## [0.9.12] - 2026-05-27 ### Added -- **transcoder diagnostic in register payload**: daemon now sends the full - HWAccel diagnostic (ffmpeg version, resolved binary path, list of HW - encoders compiled in, list of device files / drivers present) up to the - server on register. The web "Diagnose transcoder" modal surfaces these - so a user stuck on software libx264 can see *why* (e.g. ffmpeg shipped - without `--enable-nvenc`, or `/dev/nvidia0` missing inside a container) - without SSHing into their machine + running `unarr probe-hwaccel`. -- **`[transcode]` startup log line**: daemon prints a single one-line - summary of the picked backend + version + binary path + devices at - start. Same data the web shows; convenient for `journalctl --user -u - unarr | grep transcode`. +- **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 @@ -77,6 +79,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **cors**: allow play from .to / staging / onion mirrors - **library**: classify resolution by width + height, not height alone - **transcode**: make preset libx264-only + restore quality opt-in + +### Other + +- **release**: 0.9.11 ## [0.9.8] - 2026-05-27 @@ -539,9 +545,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Build - add -s -w -trimpath to Makefile, add build-small target with UPX -[0.9.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.12]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.12 +[0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15 +[0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14 +[0.9.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13 [0.9.11]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.11 [0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8 [0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 497c9a0..194e3c0 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "0.9.14" +var Version = "0.9.15" From 7a20ddb4ea3c2e6ef5c580b131f370f3404a195d Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 18:19:08 +0200 Subject: [PATCH 21/21] feat(scripts): prune Forgejo releases >90 days in ship.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/ship.sh | 54 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/ship.sh b/scripts/ship.sh index e45eab2..d81fd6f 100755 --- a/scripts/ship.sh +++ b/scripts/ship.sh @@ -17,7 +17,8 @@ # 3. Rsync to Hetzner via web/scripts/publish-cli-release.sh # 4. Multi-arch Docker build + push (amd64 + arm64) to Docker Hub # 5. Smoke checks (torrentclaw.com/version + docker run image version) -# 6. Optional `git push --follow-tags` +# 6. Prune Forgejo releases older than FORGEJO_PRUNE_DAYS (default 90) +# 7. Optional `git push --follow-tags` # # Usage: # scripts/ship.sh Detect version from internal/cmd/version.go @@ -33,6 +34,10 @@ # SKIP_DOCKER=1 skip Docker build/push # SKIP_HETZNER=1 skip Hetzner publish # SKIP_SMOKE=1 skip smoke checks +# SKIP_FORGEJO_PRUNE=1 skip Forgejo retention prune +# FORGEJO_TOKEN PAT with write:repository for prune (no token = skip + warn) +# FORGEJO_PRUNE_DAYS retention window, default 90 days +# FORGEJO_REPO default torrentclaw/unarr # set -euo pipefail @@ -44,6 +49,10 @@ PUBLISH_SCRIPT="${PUBLISH_SCRIPT:-$REPO_DIR/../torrentclaw-web/scripts/publish-c SKIP_DOCKER="${SKIP_DOCKER:-0}" SKIP_HETZNER="${SKIP_HETZNER:-0}" SKIP_SMOKE="${SKIP_SMOKE:-0}" +SKIP_FORGEJO_PRUNE="${SKIP_FORGEJO_PRUNE:-0}" +FORGEJO_PRUNE_DAYS="${FORGEJO_PRUNE_DAYS:-90}" +FORGEJO_REPO="${FORGEJO_REPO:-torrentclaw/unarr}" +FORGEJO_BASE="${FORGEJO_BASE:-https://git.torrentclaw.com}" DRY_RUN=false PUSH_TAG=false @@ -161,7 +170,48 @@ if [ "$SKIP_SMOKE" != "1" ]; then 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 info "git push origin main --follow-tags" git push origin main --follow-tags