diff --git a/.env.example b/.env.example deleted file mode 100644 index 4091938..0000000 --- a/.env.example +++ /dev/null @@ -1,16 +0,0 @@ -# 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/CHANGELOG.md b/CHANGELOG.md index fa0d872..de1dd6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,36 +5,6 @@ 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 - - -### Added - -- **scripts**: prune Forgejo releases >90 days in ship.sh - -### Fixed - -- **hls**: drop nvenc -tune ll — kills hls segmentation, bump 0.9.17 - -### Other - -- **release**: 0.9.17 ## [0.9.15] - 2026-05-27 @@ -58,7 +28,6 @@ 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 @@ -576,9 +545,6 @@ 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 [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 diff --git a/docker-compose.yml b/docker-compose.yml index 8e0b32e..5f49fcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,65 +1,48 @@ -# 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" - # 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 - - 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} + # Read-only root filesystem — only volumes are writable + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777 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). + # Config: your config.toml lives here + - ./config:/config + # Downloads: finished media goes here + - ~/Media:/downloads + # Data: torrent metadata, piece DB, cache - unarr-data:/data - # Optional: limit CPU/RAM for transcoding on shared hosts - # deploy: - # resources: - # limits: - # memory: 2G - # cpus: "4.0" + environment: + - TZ=${TZ:-UTC} + # Optional overrides (uncomment to use): + # - UNARR_API_KEY=tc_your_key_here + # - UNARR_API_URL=https://torrentclaw.com - # --- macOS / Windows alternative (replace network_mode: host above) --- - # network_mode: bridge + # Resource limits — adjust to your needs + deploy: + resources: + limits: + memory: 512M + cpus: "2.0" + + # Torrent P2P needs host network or explicit port range + # Option A: host network (simplest, full P2P performance) + network_mode: host + + # Option B: bridge network with port mapping (more isolated) + # Uncomment below and comment out network_mode above: # ports: - # - "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. + # - "6881-6889:6881-6889/tcp" + # - "6881-6889:6881-6889/udp" volumes: unarr-data: diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 425cee0..2e0c074 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -265,16 +265,15 @@ func runDaemonStart() error { // Create torrent downloader torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{ - 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, + DataDir: cfg.Download.Dir, + 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 5bf31a5..bd5ceab 100644 --- a/internal/cmd/download.go +++ b/internal/cmd/download.go @@ -119,10 +119,11 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error { return fmt.Errorf("create downloader: %w", err) } - // 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) + // 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, + ) debridDl := deps.newDebridDl() diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 8551bb1..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.19" +var Version = "0.9.15" diff --git a/internal/engine/hls.go b/internal/engine/hls.go index 8e0868a..86219d5 100644 --- a/internal/engine/hls.go +++ b/internal/engine/hls.go @@ -1150,14 +1150,10 @@ 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 + 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") + // 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") case "h264_qsv": // veryfast is the fastest realistic QSV preset; medium was too // conservative for first-start. look_ahead=0 keeps the encoder diff --git a/internal/engine/progress.go b/internal/engine/progress.go index e5eefe0..eba8814 100644 --- a/internal/engine/progress.go +++ b/internal/engine/progress.go @@ -45,19 +45,10 @@ type ProgressReporter struct { lastCheckAt time.Time // last time we reported for control-signal polling } -// 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. +// NewProgressReporter creates a reporter that flushes every interval. func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter { - var rep StatusReporter - if ac != nil { - rep = ac - } return &ProgressReporter{ - reporter: rep, + reporter: ac, interval: interval, latest: make(map[string]*Task), lastReported: make(map[string]TaskStatus), @@ -117,9 +108,6 @@ 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 { @@ -251,10 +239,6 @@ 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 efcddbe..f4b1b6d 100644 --- a/internal/engine/torrent.go +++ b/internal/engine/torrent.go @@ -61,12 +61,7 @@ var defaultTrackers = []string{ // TorrentConfig holds settings for the BitTorrent downloader. type TorrentConfig struct { - 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 + DataDir 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) @@ -118,23 +113,7 @@ 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. - // - // 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) - } + 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. @@ -373,13 +352,6 @@ 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 { @@ -487,41 +459,6 @@ 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() diff --git a/internal/funnel/funnel.go b/internal/funnel/funnel.go index 7f1b76a..6a8640a 100644 --- a/internal/funnel/funnel.go +++ b/internal/funnel/funnel.go @@ -32,13 +32,9 @@ import ( ) // urlPattern matches the `https://.trycloudflare.com` URL cloudflared -// 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`) +// 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`) // Config controls how the tunnel is launched. type Config struct { diff --git a/internal/funnel/funnel_test.go b/internal/funnel/funnel_test.go deleted file mode 100644 index fa9280d..0000000 --- a/internal/funnel/funnel_test.go +++ /dev/null @@ -1,40 +0,0 @@ -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) - } - }) - } -} diff --git a/scripts/ship.sh b/scripts/ship.sh index d81fd6f..e45eab2 100755 --- a/scripts/ship.sh +++ b/scripts/ship.sh @@ -17,8 +17,7 @@ # 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. Prune Forgejo releases older than FORGEJO_PRUNE_DAYS (default 90) -# 7. Optional `git push --follow-tags` +# 6. Optional `git push --follow-tags` # # Usage: # scripts/ship.sh Detect version from internal/cmd/version.go @@ -34,10 +33,6 @@ # 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 @@ -49,10 +44,6 @@ 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 @@ -170,48 +161,7 @@ if [ "$SKIP_SMOKE" != "1" ]; then fi fi -# 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 +# 5. Optional push if [ "$PUSH_TAG" = true ]; then info "git push origin main --follow-tags" git push origin main --follow-tags