From 7a20ddb4ea3c2e6ef5c580b131f370f3404a195d Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 27 May 2026 18:19:08 +0200 Subject: [PATCH 01/10] 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 02/10] =?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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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: From 8accafbe593a0a8a3628504d9126cb1ff439b812 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Mon, 1 Jun 2026 08:29:10 +0200 Subject: [PATCH 09/10] fix(stream): derive H.264 level from frame macroblocks, not height Anamorphic 2.39:1 scaled to 1080 height = ~2586x1080 = 11016 MBs, busting level 4.1's 8192-MB MaxFS -> nvenc "InitializeEncoder failed: Invalid Level" (libx264: "frame MB size > level limit") -> 0 segments, session stalls. Most 4K rips are 2.39:1, so HLS playback was silently broken for them. H264LevelForFrame(w,h) derives the level from the real macroblock count (max of MB-tier and height-tier). hls.go computes output width and uses it. 16:9 unchanged; anamorphic bumps to 5.0 when needed. Discovered + verified during the trickplay smoke. --- internal/engine/hls.go | 20 ++++++++---- internal/engine/hwaccel.go | 57 +++++++++++++++++++++++++++++++++ internal/engine/hwaccel_test.go | 42 ++++++++++++++++++++---- 3 files changed, 107 insertions(+), 12 deletions(-) diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 8e0868a..75cf991 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -22,6 +22,7 @@ import ( "fmt" "io" "log" + "math" "net/http" "os" "os/exec" @@ -1184,11 +1185,14 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin // 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) - // fails libx264 with "frame MB size > level limit" and emits unplayable - // segments. The output height matches qcap.MaxHeight when the source is - // downscaled, otherwise probe.Height (already populated by ffprobe). + // Derive H.264 level from the actual output FRAME (width × height), not just + // height. A fixed "4.0" caps the encoder at 1080p; deriving by height alone + // still under-levels anamorphic content — a 2.39:1 source scaled to 1080 + // height is ~2586×1080 = 11016 MBs, busting level 4.1's 8192-MB cap, which + // fails the encode ("Invalid Level" on nvenc, "frame MB size > level limit" + // on libx264) and stalls the session. The output height matches qcap.MaxHeight + // when the source is downscaled, otherwise probe.Height; the output width is + // the source width scaled by the same factor (the filter chain preserves AR). qcap := resolveQualityCap(cfg.Quality) outputHeight := qcap.MaxHeight if outputHeight == 0 { @@ -1197,7 +1201,11 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin if outputHeight == 0 || (probe.Height > 0 && probe.Height < outputHeight) { outputHeight = probe.Height } - args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(outputHeight)) + outputWidth := probe.Width + if probe.Height > 0 && outputHeight != probe.Height { + outputWidth = int(math.Round(float64(probe.Width) * float64(outputHeight) / float64(probe.Height))) + } + args = append(args, "-profile:v", "main", "-level:v", H264LevelForFrame(outputWidth, outputHeight)) // Bitrate must match the level libx264 actually picks for outputHeight, // not the qcap target for the user's requested label. If a user asks for diff --git a/internal/engine/hwaccel.go b/internal/engine/hwaccel.go index d7d1bd4..5b5907a 100644 --- a/internal/engine/hwaccel.go +++ b/internal/engine/hwaccel.go @@ -271,3 +271,60 @@ func H264LevelForHeight(height int) string { return "6.0" } } + +// h264LevelRank orders level strings so callers can pick the higher of two. +var h264LevelRank = map[string]int{ + "3.0": 30, "3.1": 31, "3.2": 32, + "4.0": 40, "4.1": 41, "4.2": 42, + "5.0": 50, "5.1": 51, "6.0": 60, +} + +// levelForMacroblocks returns the lowest H.264 level whose MaxFS (frame size in +// macroblocks) covers `mbs`. The height-based H264LevelForHeight tier is correct +// for 16:9, but anamorphic content (2.39:1 cinemascope) scaled to a given height +// has a much wider frame: a 2.39:1 source downscaled to 1080 height becomes +// ~2586×1080 = 11016 MBs, which busts level 4.1's 8192-MB MaxFS. ffmpeg then +// fails the encode — libx264 with "frame MB size > level limit", h264_nvenc with +// "InitializeEncoder failed: invalid param (8): Invalid Level" — and emits zero +// packets (the whole HLS session stalls at "preparando sesión"). MaxFS values +// from the H.264 spec, Table A-1. +func levelForMacroblocks(mbs int) string { + switch { + case mbs <= 1620: + return "3.0" + case mbs <= 3600: + return "3.1" + case mbs <= 5120: + return "3.2" + case mbs <= 8192: // levels 4.0 and 4.1 share MaxFS 8192; pick 4.1 for headroom + return "4.1" + case mbs <= 8704: + return "4.2" + case mbs <= 22080: + return "5.0" + case mbs <= 36864: + return "5.1" + default: + return "6.0" + } +} + +// H264LevelForFrame returns the lowest H.264 level that satisfies BOTH the +// height-derived tier (which carries macroblock-rate / fps headroom) and the +// actual frame's macroblock count (which catches anamorphic frames that are far +// wider than 16:9 at a given height). Use this instead of H264LevelForHeight +// wherever the output width is known — it never under-levels an ultra-wide +// frame, and for 16:9 content it returns exactly what H264LevelForHeight does. +func H264LevelForFrame(width, height int) string { + byHeight := H264LevelForHeight(height) + if width <= 0 || height <= 0 { + return byHeight + } + // Macroblocks are 16×16; partial blocks at the edge still count (ceil). + mbs := ((width + 15) / 16) * ((height + 15) / 16) + byMB := levelForMacroblocks(mbs) + if h264LevelRank[byMB] > h264LevelRank[byHeight] { + return byMB + } + return byHeight +} diff --git a/internal/engine/hwaccel_test.go b/internal/engine/hwaccel_test.go index cf3bec2..35bb08a 100644 --- a/internal/engine/hwaccel_test.go +++ b/internal/engine/hwaccel_test.go @@ -81,12 +81,12 @@ func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) { configured string wantPreset string }{ - {HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours - {HWAccelNone, "medium", "medium"}, // libx264 honours - {HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3 - {HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab - {HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast - {HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset + {HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours + {HWAccelNone, "medium", "medium"}, // libx264 honours + {HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3 + {HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab + {HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast + {HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset } for _, tc := range cases { got := ResolveEncoderProfile(tc.hw, tc.configured) @@ -154,3 +154,33 @@ func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) { } } +func TestH264LevelForFrame(t *testing.T) { + cases := []struct { + name string + width, height int + want string + }{ + // 16:9 must match the height-only helper exactly (no regression). + {"720p 16:9", 1280, 720, "4.0"}, + {"1080p 16:9", 1920, 1080, "4.1"}, + {"1440p 16:9", 2560, 1440, "5.0"}, + {"2160p 16:9", 3840, 2160, "5.1"}, + // Anamorphic 2.39:1 at 1080 height — the regression: ~2586×1080 = 11016 + // MBs busts level 4.1 (8192 MaxFS); must bump to 5.0. + {"1080h anamorphic 2.39:1", 2586, 1080, "5.0"}, + // Anamorphic 720 height (1728×720 = 4860 MBs) still fits the 4.0 the + // height floor already picks for fps headroom. + {"720h anamorphic 2.4:1", 1728, 720, "4.0"}, + // Source 4K anamorphic (3840×1604) encoded at source: 24240 MBs → 5.1. + {"4K anamorphic source", 3840, 1604, "5.1"}, + // Width unknown → fall back to the height-only tier. + {"width unknown", 0, 1080, "4.1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := H264LevelForFrame(c.width, c.height); got != c.want { + t.Errorf("H264LevelForFrame(%d,%d) = %q, want %q", c.width, c.height, got, c.want) + } + }) + } +} From c4ddd44a1a43be15918ed656d71d0589e145f2c0 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Mon, 1 Jun 2026 19:36:41 +0200 Subject: [PATCH 10/10] feat(docker): glibc base with nvenc ffmpeg + par2/7z extractors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alpine/musl can't run NVIDIA's glibc userspace (nvidia-smi, libnvidia-encode, the static nvenc ffmpeg), so HW transcode was impossible — every 4K/anamorphic HLS encode fell back to software or failed. Switch the runtime stage to debian:bookworm-slim + a static BtbN ffmpeg built with nvenc, add par2 (Usenet segment repair) + 7z (RAR/7z extraction), and set NVIDIA_DRIVER_CAPABILITIES=video,compute,utility so a plain --gpus all (or the compose device reservation) lights up nvenc with no extra flags. Falls back to libx264 automatically when no GPU is attached. Build stage cross-compiles (--platform=BUILDPLATFORM) so multi-arch stays fast; downloads forced over IPv4. --- Dockerfile | 67 ++++++++++++++++++++++++++++++++++++---------- docker-compose.yml | 14 +++++++++- 2 files changed, 66 insertions(+), 15 deletions(-) diff --git a/Dockerfile b/Dockerfile index 64ea4e2..7bb1416 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,8 @@ # ---- Build stage ---- -FROM golang:1.25-alpine AS builder +# Pin the builder to the host's native arch and cross-compile (CGO is off, so +# Go cross-compiles trivially). During multi-arch buildx this keeps `go build` +# at native speed instead of compiling under QEMU emulation for the foreign arch. +FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder RUN apk add --no-cache git ca-certificates @@ -13,34 +16,63 @@ RUN go mod download COPY . . ARG VERSION=dev -RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/ +ARG TARGETOS +ARG TARGETARCH +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/ # ---- Runtime stage ---- -FROM alpine:3.22 +# glibc base (not Alpine/musl). NVIDIA's userspace — nvidia-smi and the +# libnvidia-encode / libcuda libs that `--gpus all` injects, plus the static +# BtbN ffmpeg that links nvenc — are all glibc ELF. On musl they fail with +# "no such file or directory" (missing glibc loader), so HW transcode is +# impossible on Alpine. bookworm-slim is the smallest base that runs the full +# NVIDIA stack while still falling back to software libx264 when no GPU is +# passed in. +FROM debian:bookworm-slim -# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle / -# BtbN static glibc builds — those need a glibc shim on Alpine and the -# vector-math symbols the GPL builds reference are not satisfiable by -# gcompat. Alpine ships ffmpeg ~7.x which is fine for the HLS transcoding -# pipeline (libx264 + libfdk-aac alternatives included). -RUN apk upgrade --no-cache && \ - apk add --no-cache ca-certificates tzdata ffmpeg wget +# par2 → repair corrupted Usenet segments (without it a single bad segment +# silently corrupts the output). +# 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads +# RAR5, so unrar — unavailable as a free Debian package — isn't needed). +# tzdata/ca-certificates → TLS + correct local time for schedules/logs. +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ca-certificates tzdata wget xz-utils par2 p7zip-full && \ + rm -rf /var/lib/apt/lists/* + +# TARGETARCH is set automatically by Docker buildx during cross-builds. +ARG TARGETARCH=amd64 + +# Static GPL ffmpeg + ffprobe with nvenc compiled in (BtbN builds). nvenc is +# linked but the actual libnvidia-encode.so is dlopen'd at runtime from the +# host driver that `--gpus all` exposes — so the same binary does HW transcode +# when a GPU is present and falls back to libx264 when it isn't. Placed in +# /usr/local/bin so ResolveFFmpeg picks them up off PATH ahead of any distro +# ffmpeg. arm64 has no nvenc but the build still serves software transcode. +RUN case "$TARGETARCH" in \ + amd64) FF_ARCH=linux64 ;; \ + arm64) FF_ARCH=linuxarm64 ;; \ + *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ + esac && \ + wget -4 --tries=3 --timeout=30 -qO /tmp/ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${FF_ARCH}-gpl.tar.xz" && \ + mkdir -p /tmp/ff && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ff --strip-components=1 && \ + cp /tmp/ff/bin/ffmpeg /tmp/ff/bin/ffprobe /usr/local/bin/ && \ + chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \ + rm -rf /tmp/ffmpeg.tar.xz /tmp/ff # Bundle cloudflared so `unarr funnel on` (default: on, see config defaults) # Just Works on a headless container with no first-run network round-trip. -# TARGETARCH is set automatically by Docker buildx during cross-builds. -ARG TARGETARCH=amd64 RUN case "$TARGETARCH" in \ amd64) CF_ARCH=amd64 ;; \ arm64) CF_ARCH=arm64 ;; \ arm) CF_ARCH=armhf ;; \ *) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \ esac && \ - wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ + wget -4 --tries=3 --timeout=30 -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \ chmod +x /usr/local/bin/cloudflared # Non-root user (UID 1000 matches typical host user for volume permissions) -RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr +RUN groupadd -g 1000 unarr && useradd -u 1000 -g 1000 -m -d /home/unarr unarr # Default directories RUN mkdir -p /config /downloads /data && \ @@ -55,6 +87,13 @@ ENV UNARR_CONFIG_DIR=/config ENV UNARR_DOWNLOAD_DIR=/downloads ENV XDG_DATA_HOME=/data +# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" + +# "compute" capabilities; nvenc needs "video". Baking these here means a plain +# `docker run --gpus all` (or the compose device reservation) lights up HW +# transcode with zero extra flags. Harmless when no GPU is attached. +ENV NVIDIA_VISIBLE_DEVICES=all +ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility + VOLUME ["/config", "/downloads", "/data"] ENTRYPOINT ["unarr"] diff --git a/docker-compose.yml b/docker-compose.yml index 8e0b32e..60446db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -45,9 +45,21 @@ services: # Named volume keeps this off your media drive (avoids NFS locking issues). - unarr-data:/data - # Optional: limit CPU/RAM for transcoding on shared hosts + # --- NVIDIA GPU: hardware transcode (nvenc) --- + # Uncomment on a host with an NVIDIA GPU + nvidia-container-toolkit. The + # image already bundles an nvenc-enabled ffmpeg and sets + # NVIDIA_DRIVER_CAPABILITIES=video,compute,utility, so this device + # reservation is the only thing needed to enable HW transcode. Without a GPU + # the same image falls back to software (libx264) automatically — leave it + # commented. (docker run equivalent: add --gpus all) # deploy: # resources: + # reservations: + # devices: + # - driver: nvidia + # count: all + # capabilities: [gpu] + # # Optional: cap CPU/RAM for transcoding on shared hosts # limits: # memory: 2G # cpus: "4.0"