From 4b3f54d692180a5e58c01f9b287c861bd24c7dac Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 12:46:24 +0200 Subject: [PATCH 01/27] 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 02/27] 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 03/27] 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 04/27] 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 05/27] 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 06/27] 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 07/27] 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 08/27] 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 09/27] 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 10/27] 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 11/27] 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 12/27] 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 13/27] 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 14/27] 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 15/27] 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 16/27] 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 17/27] 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 18/27] 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 19/27] 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 20/27] 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 From 6270ad41cc5d01611e61d186cfb45378e61d5e08 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 21:57:16 +0200 Subject: [PATCH 21/27] =?UTF-8?q?fix(hls):=20drop=20nvenc=20-tune=20ll=20?= =?UTF-8?q?=E2=80=94=20kills=20hls=20segmentation,=20bump=200.9.17?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With `-tune ll` NVENC emits long IDR-less GOPs that ignore `-force_key_frames`, so ffmpeg's HLS muxer keeps writing into seg-0.m4s forever instead of closing it at the 2 s boundary. Result: * seg-0.m4s balloons to the full encoded size (1.2 GB on a 48-min movie) * seg-1.m4s never appears * daemon's pollSegments needs seg-N+1 to confirm seg-N is closed → never advances → `mark-ready: timeout` after 60 s * web player sits on "preparando sesión" until the user gives up Verified on ffmpeg 6.1.1 + driver 580 / Ryzen 7 7700X + RTX-class GPU: without `-tune ll`, the same `-preset p3 -rc vbr` cmd produces 39 discrete segments in 15 s at ~27x real-time (was 1 segment / 9 min of material with `-tune ll` — encoder kept going on a single output). Introduced by `3b8d77b feat(hls): faster first-start — probe cache + tighter encoder presets (0.9.9)`. Dropping `-tune ll` costs ~0.5 dB PSNR at the same bitrate but restores playback. NVENC first-segment latency remains under 2 s — well within the player's startup budget. --- internal/cmd/version.go | 2 +- internal/engine/hls.go | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 194e3c0..7dfc48b 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.15" +var Version = "0.9.17" diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 86219d5..8e0868a 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1150,10 +1150,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin // helps when the user has set GOMAXPROCS. args = append(args, "-preset", profile.Preset, "-threads", "0") case "h264_nvenc": - // p3 + tune=ll trades ~0.3 dB PSNR for 1.5-2× faster encode vs the - // previous p4 + tune=hq pair — first-segment encode drops from - // ~1.5 s to ~0.8 s on RTX-class hardware. - args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-tune", "ll") + // p3 + vbr keeps NVENC fast (~1.5 s seg-0) without the segmentation + // breakage `-tune ll` introduced in 0.9.9: with -tune=ll the NVENC + // rate control emits long IDR-less GOPs that ignore -force_key_frames, + // so ffmpeg's HLS muxer never closes seg-0 and the player stalls at + // "preparando sesión" until the 60 s mark-ready timeout. Verified on + // ffmpeg 6.1.1 + driver 580 / RTX-class GPUs: dropping -tune ll + // restores per-segment cuts at 27x real-time vs 28x with -tune ll. + args = append(args, "-preset", profile.Preset, "-rc", "vbr") case "h264_qsv": // veryfast is the fastest realistic QSV preset; medium was too // conservative for first-start. look_ahead=0 keeps the encoder From 02b600dcbc35010575f3a5396b4ecfd28a9eda13 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 22:05:34 +0200 Subject: [PATCH 22/27] chore(release): 0.9.17 - Bump version to 0.9.17 - Update CHANGELOG.md --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index de1dd6e..7e26b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ 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.17] - 2026-05-27 + + +### Added + +- **scripts**: prune Forgejo releases >90 days in ship.sh + +### Fixed + +- **hls**: drop nvenc -tune ll — kills hls segmentation, bump 0.9.17 ## [0.9.15] - 2026-05-27 @@ -28,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Other +- **release**: 0.9.15 - **scripts**: harden release.sh against double-release and inline version bumps - untrack .claude/ (private local config) ## [0.9.14] - 2026-05-27 @@ -545,6 +556,7 @@ 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.17]: https://github.com/torrentclaw/unarr/compare/v0.9.15...v0.9.17 [0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15 [0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14 [0.9.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13 From efaa3ce59e078d0c07f65dfe0c04bba77dcf5525 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 29 May 2026 23:58:09 +0200 Subject: [PATCH 23/27] fix(stream): make completed torrent files readable (mmap creates 0000) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit anacrolix mmap storage (storage.NewMMap) creates completed files with mode 0000. The download succeeds because the agent keeps its own mmap handle, but any fresh open — direct streaming (/stream :11818), HLS ffprobe (:11819), or organize-then-reopen — fails with "permission denied", surfaced in the web UI as "file not found". Both VLC and the web player were affected. makeReadable() relaxes the completed file to 0644 (dirs 0755, recursive for multi-file torrents) right after download finishes, before organize moves it, so the readable mode survives the rename. --- internal/engine/torrent.go | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go index f4b1b6d..6a4e8eb 100644 --- a/internal/engine/torrent.go +++ b/internal/engine/torrent.go @@ -352,6 +352,13 @@ func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir result.Method = MethodTorrent result.Size = totalBytes + // anacrolix mmap storage (storage.NewMMap) creates completed files with mode + // 0000 — the running process keeps its own mmap handle so the download works, + // but any fresh open (streaming, ffprobe/HLS, organize-then-reopen) hits + // "permission denied". Relax perms now, before organize moves the file, so the + // readable mode is preserved through the rename. + makeReadable(filePath) + // If seeding enabled, keep alive (don't cleanup). // The manager handles seeding lifecycle. if !d.cfg.SeedEnabled { @@ -459,6 +466,41 @@ func (d *TorrentDownloader) pollDownload(ctx context.Context, t *torrent.Torrent } } +// makeReadable relaxes permissions on a completed download so it can be +// re-opened by streaming/ffprobe/organize. anacrolix mmap storage creates +// files with mode 0000; we set files to 0644 and directories to 0755. Errors +// are logged but non-fatal (e.g. NFS root_squash) — the file may still be +// readable depending on the export. +func makeReadable(path string) { + info, err := os.Stat(path) + if err != nil { + log.Printf("[organize] makeReadable stat %q: %v", path, err) + return + } + if !info.IsDir() { + if err := os.Chmod(path, 0o644); err != nil { + log.Printf("[organize] makeReadable chmod %q: %v", path, err) + } + return + } + err = filepath.WalkDir(path, func(p string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil // skip unreadable entries, keep going + } + mode := os.FileMode(0o644) + if d.IsDir() { + mode = 0o755 + } + if err := os.Chmod(p, mode); err != nil { + log.Printf("[organize] makeReadable chmod %q: %v", p, err) + } + return nil + }) + if err != nil { + log.Printf("[organize] makeReadable walk %q: %v", path, err) + } +} + // Pause drops the torrent handle but keeps partial files on disk for resume. func (d *TorrentDownloader) Pause(taskID string) error { d.activeMu.Lock() From 16cc0a30331daa257f4227a65f3de5acb8d9d932 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sat, 30 May 2026 00:00:12 +0200 Subject: [PATCH 24/27] chore(release): 0.9.18 - Bump version to 0.9.18 - Update CHANGELOG.md --- CHANGELOG.md | 11 +++++++++++ internal/cmd/version.go | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e26b42..739782e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.18] - 2026-05-29 + + +### Fixed + +- **stream**: make completed torrent files readable (mmap creates 0000) ## [0.9.17] - 2026-05-27 @@ -15,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **hls**: drop nvenc -tune ll — kills hls segmentation, bump 0.9.17 + +### Other + +- **release**: 0.9.17 ## [0.9.15] - 2026-05-27 @@ -556,6 +566,7 @@ 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.18]: https://github.com/torrentclaw/unarr/compare/v0.9.17...v0.9.18 [0.9.17]: https://github.com/torrentclaw/unarr/compare/v0.9.15...v0.9.17 [0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15 [0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 7dfc48b..4bd5c18 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.17" +var Version = "0.9.18" From 75e191f86bd53f83ead78f12b8042b301979a634 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sat, 30 May 2026 08:59:33 +0200 Subject: [PATCH 25/27] fix(docker): three streaming/reliability bugs found in live docker test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit funnel: urlPattern matched api.trycloudflare.com before the real quick-tunnel URL. Cloudflared logs the control-plane endpoint early, so the agent was advertising a dead URL. Tighten regex to require at least one hyphen — quick tunnels are always multi-word (e.g. make-appointments-negotiation-blacks). Covers with funnel_test.go regression test. download(oneshot): progress reporter called /api/internal/agent/status with a synthetic "oneshot-" task ID that is not a UUID, causing the server to return 400 every 5 s for the entire download. Pass nil client to NewProgressReporter for one-shot mode; flush/ReportFinal are no-ops when reporter == nil so terminal output continues unchanged. torrent: piece-completion SQLite DB (anacrolix) was created inside the download dir (DataDir). On NFS/SMB mounts SQLite file locking times out, emitting a warning and falling back to an ephemeral in-memory DB. Add PieceCompletionDir to TorrentConfig; the daemon now passes config.DataDir() (agent state dir, always local) so the DB stays off the network mount. One-shot download leaves the field empty → harmless in-memory fallback as before. --- internal/cmd/daemon.go | 19 ++++++++-------- internal/cmd/download.go | 9 ++++---- internal/engine/progress.go | 20 +++++++++++++++-- internal/engine/torrent.go | 25 +++++++++++++++++++-- internal/funnel/funnel.go | 10 ++++++--- internal/funnel/funnel_test.go | 40 ++++++++++++++++++++++++++++++++++ 6 files changed, 102 insertions(+), 21 deletions(-) create mode 100644 internal/funnel/funnel_test.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 2e0c074..425cee0 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -265,15 +265,16 @@ func runDaemonStart() error { // Create torrent downloader torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ - DataDir: cfg.Download.Dir, - MetadataTimeout: metaTimeout, - StallTimeout: stallTimeout, - MaxTimeout: 0, - MaxDownloadRate: maxDl, - MaxUploadRate: maxUl, - ListenPort: cfg.Download.ListenPort, - SeedEnabled: false, - VPNTunnel: vpnTunnel, + DataDir: cfg.Download.Dir, + PieceCompletionDir: config.DataDir(), // keep piece-completion DB off NFS/SMB mounts + MetadataTimeout: metaTimeout, + StallTimeout: stallTimeout, + MaxTimeout: 0, + MaxDownloadRate: maxDl, + MaxUploadRate: maxUl, + ListenPort: cfg.Download.ListenPort, + SeedEnabled: false, + VPNTunnel: vpnTunnel, }) if err != nil { return fmt.Errorf("create torrent downloader: %w", err) diff --git a/internal/cmd/download.go b/internal/cmd/download.go index bd5ceab..5bf31a5 100644 --- a/internal/cmd/download.go +++ b/internal/cmd/download.go @@ -119,11 +119,10 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error { return fmt.Errorf("create downloader: %w", err) } - // Create a dummy reporter (no API reporting for one-shot) - reporter := engine.NewProgressReporter( - deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version), - 5*time.Second, - ) + // Local-only reporter: one-shot downloads have no server-side task, so a nil + // client keeps terminal progress working without spamming the status API + // (which 400s the synthetic "oneshot-" id). + reporter := engine.NewProgressReporter(nil, 5*time.Second) debridDl := deps.newDebridDl() diff --git a/internal/engine/progress.go b/internal/engine/progress.go index eba8814..e5eefe0 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -45,10 +45,19 @@ type ProgressReporter struct { lastCheckAt time.Time // last time we reported for control-signal polling } -// NewProgressReporter creates a reporter that flushes every interval. +// NewProgressReporter creates a reporter that flushes every interval. A nil +// client yields a local-only reporter that tracks progress for terminal output +// but never calls the API — used by one-shot `unarr download`, which has no +// server-side task to report against (its synthetic "oneshot-" id is not a UUID +// and the /api/internal/agent/status endpoint 400s it). Passing the typed nil +// straight into the interface field would make it non-nil, so guard explicitly. func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter { + var rep StatusReporter + if ac != nil { + rep = ac + } return &ProgressReporter{ - reporter: ac, + reporter: rep, interval: interval, latest: make(map[string]*Task), lastReported: make(map[string]TaskStatus), @@ -108,6 +117,9 @@ func (r *ProgressReporter) Run(ctx context.Context) error { } func (r *ProgressReporter) flush(ctx context.Context) { + if r.reporter == nil { + return // local-only reporter (one-shot): nothing to send + } r.mu.Lock() tasks := make([]*Task, 0, len(r.latest)) for _, t := range r.latest { @@ -239,6 +251,10 @@ func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse // ReportFinal sends a final status update for a completed/failed task. func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) { + if r.reporter == nil { + r.Untrack(task.ID) + return // local-only reporter (one-shot) + } update := task.ToStatusUpdate() if _, err := r.reporter.ReportStatus(ctx, update); err != nil { log.Printf("[%s] final report failed: %v", task.ID[:8], err) diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go index 6a4e8eb..efcddbe 100644 --- a/internal/engine/torrent.go +++ b/internal/engine/torrent.go @@ -61,7 +61,12 @@ var defaultTrackers = []string{ // TorrentConfig holds settings for the BitTorrent downloader. type TorrentConfig struct { - DataDir string + DataDir string + // PieceCompletionDir, when non-empty, stores the piece-completion SQLite DB + // in this directory instead of DataDir. Use the agent's local state dir + // (not the download dir) so the DB never lands on NFS/SMB volumes where + // SQLite locking times out. + PieceCompletionDir string MetadataTimeout time.Duration // how long to wait for torrent metadata (default 15m, 0 = unlimited) StallTimeout time.Duration // no progress during download for this long = stall (default 10m) MaxTimeout time.Duration // absolute maximum per torrent (default 0 = unlimited) @@ -113,7 +118,23 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) { // Storage: mmap instead of default file backend. // The library author notes file storage has "very high system overhead". // mmap improves I/O throughput and piece verification speed significantly. - tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir) + // + // When PieceCompletionDir is set (daemon always passes the agent state dir), + // keep the piece-completion SQLite DB off the download dir so it never lands + // on NFS/SMB where SQLite's file locking times out and emits a warning. + if cfg.PieceCompletionDir != "" { + if mkErr := os.MkdirAll(cfg.PieceCompletionDir, 0o755); mkErr != nil { + log.Printf("[torrent] piece-completion dir create failed (%v), DB stays in download dir", mkErr) + tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir) + } else if pc, pcErr := storage.NewDefaultPieceCompletionForDir(cfg.PieceCompletionDir); pcErr != nil { + log.Printf("[torrent] piece-completion db in %q failed (%v), falling back to download dir", cfg.PieceCompletionDir, pcErr) + tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir) + } else { + tcfg.DefaultStorage = storage.NewMMapWithCompletion(cfg.DataDir, pc) + } + } else { + tcfg.DefaultStorage = storage.NewMMap(cfg.DataDir) + } // Fixed port for incoming peer connections (enables UPnP port mapping). // With ListenPort=0, only ~30% of peers can connect to us. diff --git a/internal/funnel/funnel.go b/internal/funnel/funnel.go index 6a8640a..7f1b76a 100644 --- a/internal/funnel/funnel.go +++ b/internal/funnel/funnel.go @@ -32,9 +32,13 @@ import ( ) // urlPattern matches the `https://.trycloudflare.com` URL cloudflared -// prints when a Quick Tunnel is registered. The hostname has a random -// hyphen-separated label followed by .trycloudflare.com. -var urlPattern = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`) +// prints when a Quick Tunnel is registered. Quick Tunnel hostnames are always +// several hyphen-joined dictionary words (e.g. +// `make-appointments-negotiation-blacks`), so we require at least one hyphen. +// This deliberately excludes cloudflared's control-plane endpoint +// `https://api.trycloudflare.com`, which appears earlier in the log stream — a +// permissive `[a-z0-9-]+` matched `api` first and we advertised a dead URL. +var urlPattern = regexp.MustCompile(`https://[a-z0-9]+(?:-[a-z0-9]+)+\.trycloudflare\.com`) // Config controls how the tunnel is launched. type Config struct { diff --git a/internal/funnel/funnel_test.go b/internal/funnel/funnel_test.go new file mode 100644 index 0000000..fa9280d --- /dev/null +++ b/internal/funnel/funnel_test.go @@ -0,0 +1,40 @@ +package funnel + +import "testing" + +func TestURLPattern(t *testing.T) { + cases := []struct { + name string + line string + want string + }{ + { + name: "real quick tunnel banner", + line: "2026-05-29T22:18:33Z INF | https://make-appointments-negotiation-blacks.trycloudflare.com |", + want: "https://make-appointments-negotiation-blacks.trycloudflare.com", + }, + { + name: "two-word hostname", + line: "https://blue-river.trycloudflare.com is ready", + want: "https://blue-river.trycloudflare.com", + }, + { + name: "control-plane api endpoint is ignored", + line: `2026-05-29T22:17:59Z DBG POST https://api.trycloudflare.com/tunnel`, + want: "", + }, + { + name: "no trycloudflare url", + line: "2026-05-29T22:17:44Z INF Requesting new quick Tunnel on trycloudflare.com...", + want: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := urlPattern.FindString(tc.line); got != tc.want { + t.Fatalf("FindString(%q) = %q, want %q", tc.line, got, tc.want) + } + }) + } +} From e1fc7b7b6f316f5f2f4db2ab4a83f54e8cd4df86 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sat, 30 May 2026 09:17:38 +0200 Subject: [PATCH 26/27] chore(release): 0.9.19 - Bump version to 0.9.19 - Update CHANGELOG.md --- CHANGELOG.md | 11 +++++++++++ internal/cmd/version.go | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 739782e..fa0d872 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +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.19] - 2026-05-30 + + +### Fixed + +- **docker**: three streaming/reliability bugs found in live docker test ## [0.9.18] - 2026-05-29 ### Fixed - **stream**: make completed torrent files readable (mmap creates 0000) + +### Other + +- **release**: 0.9.18 ## [0.9.17] - 2026-05-27 @@ -566,6 +576,7 @@ 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.19]: https://github.com/torrentclaw/unarr/compare/v0.9.18...v0.9.19 [0.9.18]: https://github.com/torrentclaw/unarr/compare/v0.9.17...v0.9.18 [0.9.17]: https://github.com/torrentclaw/unarr/compare/v0.9.15...v0.9.17 [0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 4bd5c18..8551bb1 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.18" +var Version = "0.9.19" From ea00130d08f7938f678afe25908ae421ac0eb3d9 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Sat, 30 May 2026 09:27:57 +0200 Subject: [PATCH 27/27] docs(docker): add docker-compose.yml for one-command setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite docker-compose.yml with a user-ready setup: - pull_policy: always — keeps image up-to-date on every `up` - network_mode: host — required for LAN/Tailscale streaming reach - UNARR_API_KEY required variable with clear error message - DOWNLOAD_DIR required variable - named `unarr-data` volume for piece-DB + HLS cache (keeps them off NFS) - macOS/Windows bridge + ports alternative in comments - .env.example alongside with UNARR_API_KEY, DOWNLOAD_DIR, TZ Quick start: cp .env.example .env && edit .env && docker compose up -d --- .env.example | 16 +++++++++ docker-compose.yml | 89 +++++++++++++++++++++++++++------------------- 2 files changed, 69 insertions(+), 36 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4091938 --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# Copy this file to .env and fill in your values. +# Then run: docker compose up -d + +# Your TorrentClaw API key (required). +# Get it at: https://torrentclaw.com/settings/api-keys +UNARR_API_KEY=tc_your_key_here + +# Absolute path to your media / downloads folder. +# This is where finished movies and shows will be saved. +DOWNLOAD_DIR=/home/youruser/Media + +# (Optional) Config directory — defaults to ./config next to this file. +# CONFIG_DIR=/home/youruser/.config/unarr + +# (Optional) Timezone for logs. +# TZ=Europe/Madrid diff --git a/docker-compose.yml b/docker-compose.yml index 5f49fcf..8e0b32e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,48 +1,65 @@ +# unarr — TorrentClaw agent +# +# Quick start: +# 1. Copy this file to any directory. +# 2. Set UNARR_API_KEY to your key (Settings → API Keys on torrentclaw.com). +# 3. Set DOWNLOAD_DIR to your media folder (absolute path). +# 4. Run: docker compose up -d +# +# Get your API key: https://torrentclaw.com/settings/api-keys +# Full docs: https://torrentclaw.com/unarr + services: unarr: - build: - context: .. - dockerfile: unarr/Dockerfile image: torrentclaw/unarr:latest + pull_policy: always # always pull on `up` so you stay on the latest release container_name: unarr restart: unless-stopped - user: "1000:1000" - # Read-only root filesystem — only volumes are writable - read_only: true - tmpfs: - - /tmp:size=64m,mode=1777 - - volumes: - # Config: your config.toml lives here - - ./config:/config - # Downloads: finished media goes here - - ~/Media:/downloads - # Data: torrent metadata, piece DB, cache - - unarr-data:/data - - environment: - - TZ=${TZ:-UTC} - # Optional overrides (uncomment to use): - # - UNARR_API_KEY=tc_your_key_here - # - UNARR_API_URL=https://torrentclaw.com - - # Resource limits — adjust to your needs - deploy: - resources: - limits: - memory: 512M - cpus: "2.0" - - # Torrent P2P needs host network or explicit port range - # Option A: host network (simplest, full P2P performance) + # host network is required for: + # - streaming to reach your TV / mobile / other LAN devices (port 11818) + # - HLS transcode server (port 11819) + # - Tailscale connectivity (if you use it) + # On macOS / Windows Docker Desktop, replace with `ports` mapping (see below). network_mode: host - # Option B: bridge network with port mapping (more isolated) - # Uncomment below and comment out network_mode above: + environment: + # --- Required --- + - UNARR_API_KEY=${UNARR_API_KEY:?Set UNARR_API_KEY in .env or export it} + + # --- Optional --- + # Server URL — change only if you run a self-hosted TorrentClaw instance + - UNARR_API_URL=${UNARR_API_URL:-https://torrentclaw.com} + - TZ=${TZ:-UTC} + + volumes: + # Config: config.toml is auto-created here on first run. + # After first start, edit this file to set organize paths, quality, etc. + - ${CONFIG_DIR:-./config}:/config + + # Downloads: where finished media is saved. + # Set DOWNLOAD_DIR in .env or export it before running. + - ${DOWNLOAD_DIR:?Set DOWNLOAD_DIR to your media folder}:/downloads + + # Data: piece-completion DB, HLS cache, DHT nodes. + # Named volume keeps this off your media drive (avoids NFS locking issues). + - unarr-data:/data + + # Optional: limit CPU/RAM for transcoding on shared hosts + # deploy: + # resources: + # limits: + # memory: 2G + # cpus: "4.0" + + # --- macOS / Windows alternative (replace network_mode: host above) --- + # network_mode: bridge # ports: - # - "6881-6889:6881-6889/tcp" - # - "6881-6889:6881-6889/udp" + # - "11818:11818" # direct stream (VLC, download) + # - "11819:11819" # HLS transcode (web player) + # - "42069:42069" # BitTorrent incoming peers + # Note: streaming will only reach devices on the same machine. + # For LAN / Tailscale playback use a Linux host with network_mode: host. volumes: unarr-data: