From 7a20ddb4ea3c2e6ef5c580b131f370f3404a195d Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 18:19:08 +0200 Subject: [PATCH 1/8] 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 2/8] =?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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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: