From f1b4f2e3279372bde2483865962bfc5493d796e9 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 9 Apr 2026 16:15:41 +0200 Subject: [PATCH 01/89] fix(stream): fix black screen on remote/Tailscale streaming Three root-cause fixes for VLC showing a black screen when opening a stream from a different network or via Tailscale: 1. PrioritizeTail: when VLC opens an MKV/MP4 stream it immediately seeks to the end of the file to read the container index (seekhead/moov atom). For active torrents those end-pieces aren't downloaded yet, so the reader blocks indefinitely. PrioritizeTail() opens a background reader positioned at the last 5 MB, keeping those pieces at high priority until ctx is cancelled or they finish downloading. 2. /health endpoint: GET /health returns a lightweight JSON response {"status":"ok","streaming":bool,...} so connectivity can be tested with a simple curl from any device before involving VLC. 3. Per-request logging: every incoming /stream request now logs the client IP and Range header, making it trivial to confirm whether remote/Tailscale clients are reaching the server at all. --- internal/cmd/stream_handler.go | 7 +++ internal/engine/stream.go | 32 ++++++++++++ internal/engine/stream_server.go | 44 ++++++++++++++++ internal/engine/stream_server_test.go | 74 +++++++++++++++++++++++++++ internal/engine/stream_test.go | 28 ++++++++++ 5 files changed, 185 insertions(+) diff --git a/internal/cmd/stream_handler.go b/internal/cmd/stream_handler.go index aec884b..fa61220 100644 --- a/internal/cmd/stream_handler.go +++ b/internal/cmd/stream_handler.go @@ -148,6 +148,13 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine task.StreamURL = srv.URLsJSON() log.Printf("[%s] stream ready: %s (url: %s)", at.ID[:8], eng.FileName(), srv.URL()) + // Pre-descargar los últimos 5 MB del archivo para que el moov atom (MP4) + // o el seekhead (MKV) estén disponibles cuando VLC los pida al abrir el + // stream. Sin esto, VLC busca el final del archivo, el lector bloquea + // esperando piezas no descargadas, y el resultado es pantalla negra en + // redes remotas donde la latencia amplifica el efecto. + eng.PrioritizeTail(ctx, 5*1024*1024) + // 5. Start watch progress reporter if agentClient != nil { watchReporter := engine.NewWatchReporter(agentClient, srv, at.ID) diff --git a/internal/engine/stream.go b/internal/engine/stream.go index af644b7..1414f15 100644 --- a/internal/engine/stream.go +++ b/internal/engine/stream.go @@ -303,6 +303,38 @@ func (s *StreamEngine) FileSize() int64 { return s.totalBytes } // BufferTarget returns the buffer threshold in bytes. func (s *StreamEngine) BufferTarget() int64 { return s.bufferTarget } +// PrioritizeTail abre un lector posicionado cerca del final del archivo para +// forzar la descarga anticipada de los metadatos del container (moov atom en +// MP4, seekhead en MKV). Sin esto, VLC busca el final del archivo al abrirlo +// y el lector bloquea indefinidamente si esas piezas aún no están descargadas, +// resultando en pantalla negra en redes lentas o remotas. +// +// Se ejecuta en una goroutine y se cancela cuando ctx expira. +func (s *StreamEngine) PrioritizeTail(ctx context.Context, tailBytes int64) { + if s.file == nil || s.totalBytes <= tailBytes*2 { + return + } + go func() { + reader := s.file.NewReader() + defer reader.Close() + + seekPos := s.totalBytes - tailBytes + reader.Seek(seekPos, io.SeekStart) //nolint:errcheck + reader.SetReadahead(tailBytes) + reader.SetContext(ctx) + + // Leer continuamente para mantener las piezas priorizadas hasta que + // ctx se cancele o el final del archivo esté completamente descargado. + buf := make([]byte, 32*1024) + for { + _, err := reader.Read(buf) + if err != nil { + return + } + } + }() +} + // Shutdown gracefully closes the torrent and client. func (s *StreamEngine) Shutdown(_ context.Context) error { if s.tor != nil { diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 492bf7a..359d0b1 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -71,6 +71,7 @@ func NewStreamServer(port int) *StreamServer { func (ss *StreamServer) Listen(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("/stream", ss.handler) + mux.HandleFunc("/health", ss.healthHandler) // SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart) lc := net.ListenConfig{ @@ -234,9 +235,52 @@ func (ss *StreamServer) Shutdown(ctx context.Context) error { return nil } +// healthHandler responde con el estado del servidor en JSON. +// Útil para diagnosticar conectividad desde redes remotas o Tailscale: +// +// curl http://:/health +func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) { + ss.mu.RLock() + provider := ss.provider + taskID := ss.taskID + ss.mu.RUnlock() + + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + + type healthResponse struct { + Status string `json:"status"` + Streaming bool `json:"streaming"` + File string `json:"file,omitempty"` + Task string `json:"task,omitempty"` + Port int `json:"port"` + Client string `json:"client"` + } + resp := healthResponse{ + Status: "ok", + Port: ss.port, + Client: clientIP, + } + if provider != nil { + resp.Streaming = true + resp.File = provider.FileName() + resp.Task = taskID + if len(resp.Task) > 8 { + resp.Task = resp.Task[:8] + } + } + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + json.NewEncoder(w).Encode(resp) //nolint:errcheck +} + func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) { ss.lastActivity.Store(time.Now().UnixNano()) + // Log every incoming request — essential for diagnosing remote/Tailscale issues. + clientIP, _, _ := net.SplitHostPort(r.RemoteAddr) + log.Printf("[stream] %s /stream from %s Range:%q", r.Method, clientIP, r.Header.Get("Range")) + // Get current provider (may be nil if no file is being served) ss.mu.RLock() provider := ss.provider diff --git a/internal/engine/stream_server_test.go b/internal/engine/stream_server_test.go index 8802ff9..623a16d 100644 --- a/internal/engine/stream_server_test.go +++ b/internal/engine/stream_server_test.go @@ -305,6 +305,80 @@ func TestStreamServer_SetFile_SwapsProvider(t *testing.T) { } } +// TestStreamServer_Health_NoFile verifica que /health devuelve streaming:false +// cuando no hay archivo configurado. +func TestStreamServer_Health_NoFile(t *testing.T) { + srv := NewStreamServer(0) + ctx := context.Background() + + if err := srv.Listen(ctx); err != nil { + t.Fatalf("Listen() error: %v", err) + } + defer srv.Shutdown(ctx) + + healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port()) + resp, err := http.Get(healthURL) + if err != nil { + t.Fatalf("GET /health: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type = %q, want application/json", ct) + } + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + if !strings.Contains(bodyStr, `"streaming":false`) { + t.Errorf("body = %q, want streaming:false", bodyStr) + } + if !strings.Contains(bodyStr, `"status":"ok"`) { + t.Errorf("body = %q, want status:ok", bodyStr) + } +} + +// TestStreamServer_Health_WithFile verifica que /health devuelve streaming:true +// y el nombre del archivo cuando hay un archivo configurado. +func TestStreamServer_Health_WithFile(t *testing.T) { + srv := NewStreamServer(0) + ctx := context.Background() + + if err := srv.Listen(ctx); err != nil { + t.Fatalf("Listen() error: %v", err) + } + defer srv.Shutdown(ctx) + + provider := newFakeProvider("pelicula.mkv", []byte("contenido de prueba")) + srv.SetFile(provider, "task-health-test") + + healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port()) + resp, err := http.Get(healthURL) + if err != nil { + t.Fatalf("GET /health: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status = %d, want 200", resp.StatusCode) + } + + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + if !strings.Contains(bodyStr, `"streaming":true`) { + t.Errorf("body = %q, want streaming:true", bodyStr) + } + if !strings.Contains(bodyStr, "pelicula.mkv") { + t.Errorf("body = %q, want file name pelicula.mkv", bodyStr) + } + if !strings.Contains(bodyStr, "task-hea") { // primeros 8 chars de "task-health-test" + t.Errorf("body = %q, want task short ID", bodyStr) + } +} + // TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv // es el correcto. func TestStreamServer_MKV_ContentType(t *testing.T) { diff --git a/internal/engine/stream_test.go b/internal/engine/stream_test.go index 61e1612..df473a0 100644 --- a/internal/engine/stream_test.go +++ b/internal/engine/stream_test.go @@ -380,3 +380,31 @@ func (r *responseRecorder) ReadFrom(src io.Reader) (int64, error) { n, err := io.Copy(r.body, src) return n, err } + +// TestPrioritizeTail_SmallFile verifica que PrioritizeTail no lanza goroutine +// cuando el archivo es demasiado pequeño (≤ 2×tailBytes). +func TestPrioritizeTail_SmallFile(t *testing.T) { + s := &StreamEngine{ + totalBytes: 5 * 1024 * 1024, // 5 MB — menor que 2×5 MB + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // No debe entrar en pánico ni bloquear con file == nil + s.PrioritizeTail(ctx, 5*1024*1024) + // Si llega aquí sin pánico, el test pasa +} + +// TestPrioritizeTail_NilFile verifica que PrioritizeTail es seguro cuando +// file es nil (engine no inicializado). +func TestPrioritizeTail_NilFile(t *testing.T) { + s := &StreamEngine{ + totalBytes: 100 * 1024 * 1024, + file: nil, + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s.PrioritizeTail(ctx, 5*1024*1024) + // No debe entrar en pánico +} From b3f2b3e64d47d29072ffa677fdd6f963657b1f9e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 9 Apr 2026 18:37:56 +0200 Subject: [PATCH 02/89] chore(release): 0.6.6 - Bump version to 0.6.6 - 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 3609397..96931f6 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.6.6] - 2026-04-09 + + +### Fixed + +- **stream**: fix black screen on remote/Tailscale streaming ## [0.6.5] - 2026-04-09 ### Fixed - **upgrade**: retry download on transient network errors with user feedback + +### Other + +- **release**: 0.6.5 ## [0.6.4] - 2026-04-09 @@ -218,6 +228,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - remove UPX compression (antivirus false positives, startup penalty) - add -s -w -trimpath to Makefile, add build-small target with UPX +[0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6 [0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5 [0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4 [0.6.3]: https://github.com/torrentclaw/unarr/compare/v0.6.2...v0.6.3 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 3d8ea02..1669d95 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.6.5" +var Version = "0.6.6" From b2ed81ee744e8b9f807f49d6fe25b289afb7368f Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Thu, 9 Apr 2026 19:25:28 +0200 Subject: [PATCH 03/89] fix(docker): switch ffprobe download from johnvansickle.com to BtbN/FFmpeg-Builds johnvansickle.com was unreachable from GitHub Actions runners (2 failed releases), switching to BtbN static builds on GitHub CDN which are more reliable. --- Dockerfile | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index f7650f0..f0e816f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,23 @@ # ---- ffprobe static binary stage ---- -# Download a static ffprobe-only build (~30MB) to avoid the full ffmpeg package (~1GB). -# johnvansickle.com provides reliable static builds for amd64/arm64. +# Download a static ffprobe build from BtbN/FFmpeg-Builds (GitHub CDN, reliable). FROM alpine:3.22 AS ffprobe-dl RUN apk add --no-cache curl xz RUN ARCH=$(uname -m) && \ case "$ARCH" in \ - x86_64) SLUG="amd64" ;; \ - aarch64) SLUG="arm64" ;; \ + x86_64) SLUG="linux64" ;; \ + aarch64) SLUG="linuxarm64" ;; \ *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ esac && \ - curl -fsSL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-${SLUG}-static.tar.xz" -o /tmp/ff.tar.xz && \ - tar xJ -f /tmp/ff.tar.xz --strip-components=1 -C /tmp/ && \ - mv /tmp/ffprobe /usr/local/bin/ffprobe && \ + curl -fsSL --retry 3 --retry-delay 5 \ + "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${SLUG}-gpl.tar.xz" \ + -o /tmp/ff.tar.xz && \ + mkdir /tmp/ffbuild && \ + tar xJ -f /tmp/ff.tar.xz --strip-components=1 -C /tmp/ffbuild/ && \ + mv /tmp/ffbuild/bin/ffprobe /usr/local/bin/ffprobe && \ chmod +x /usr/local/bin/ffprobe && \ - rm -rf /tmp/ff.tar.xz /tmp/ffmpeg /tmp/ffmpeg-* && \ + rm -rf /tmp/ff.tar.xz /tmp/ffbuild && \ ffprobe -version | head -1 # ---- Build stage ---- From db316726fdf8d059a4cdcbd9a9f7a446aa5debe8 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 11:46:20 +0200 Subject: [PATCH 04/89] feat(scan): always scan downloads + organize dirs, deduplicate child paths ResolveScanPaths() collects downloads.dir, organize.movies_dir, organize.tv_shows_dir, and library.scan_path (if set), then removes paths that are subdirectories of a parent already in the list. This ensures the daemon and CLI scan all configured dirs without relying solely on scan_path being set. --- internal/cmd/daemon.go | 101 ++++++++++++++++++++++---------------- internal/cmd/scan.go | 13 +++-- internal/library/paths.go | 55 +++++++++++++++++++++ 3 files changed, 122 insertions(+), 47 deletions(-) create mode 100644 internal/library/paths.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index a6e892a..e4abcc6 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -401,20 +401,15 @@ func runDaemonStart() error { }() // Start auto-scan goroutine - scanPath := cfg.Library.ScanPath - if scanPath == "" { - scanPath = cfg.Download.Dir - } - if scanPath != "" && cfg.Library.AutoScan { - scanCfg := cfg - scanCfg.Library.ScanPath = scanPath + scanPaths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath) + if len(scanPaths) > 0 && cfg.Library.AutoScan { scanInterval := 24 * time.Hour if cfg.Library.ScanInterval != "" { if parsed, err := time.ParseDuration(cfg.Library.ScanInterval); err == nil && parsed > 0 { scanInterval = parsed } } - go runAutoScan(ctx, scanCfg, scanInterval, agentClient, d.ScanNow) + go runAutoScan(ctx, cfg, scanInterval, agentClient, d.ScanNow, scanPaths) } // Start reporter only for stream task handling @@ -491,8 +486,10 @@ func formatSpeedLog(bps int64) string { } // runAutoScan runs a library scan + sync on a timer or on-demand via scanNow channel. -func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client, scanNow <-chan struct{}) { - log.Printf("[auto-scan] enabled: every %s, path: %s", interval, cfg.Library.ScanPath) +// It scans all provided paths and syncs each independently so stale-item cleanup +// is scoped to the correct directory prefix on the server. +func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, ac *agent.Client, scanNow <-chan struct{}, scanPaths []string) { + log.Printf("[auto-scan] enabled: every %s, paths: %v", interval, scanPaths) select { case <-time.After(30 * time.Second): @@ -507,7 +504,7 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, log.Printf("[auto-scan] panic recovered: %v", r) } }() - log.Printf("[auto-scan] starting scan of %s", cfg.Library.ScanPath) + log.Printf("[auto-scan] starting scan of %v", scanPaths) existing, _ := library.LoadCache() @@ -516,49 +513,67 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration, workers = 8 } - cache, err := library.Scan(ctx, cfg.Library.ScanPath, existing, library.ScanOptions{ + scanOpts := library.ScanOptions{ Workers: workers, FFprobePath: cfg.Library.FFprobePath, Incremental: existing != nil, - }) - if err != nil { - log.Printf("[auto-scan] scan failed: %v", err) - return - } - - if err := library.SaveCache(cache); err != nil { - log.Printf("[auto-scan] save cache failed: %v", err) - return - } - - items := library.BuildSyncItems(cache) - if len(items) == 0 { - log.Printf("[auto-scan] no items to sync") - return } + // Scan each path independently and sync per path so the server can + // scope stale-item deletion to the correct directory prefix. const batchSize = 100 - syncStartedAt := time.Now().UTC().Format(time.RFC3339) - for i := 0; i < len(items); i += batchSize { - end := i + batchSize - if end > len(items) { - end = len(items) - } - isLast := end >= len(items) + totalSynced := 0 + var mergedItems []library.LibraryItem - _, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{ - Items: items[i:end], - ScanPath: cache.Path, - IsLastBatch: isLast, - SyncStartedAt: syncStartedAt, - }) + for _, scanPath := range scanPaths { + cache, err := library.Scan(ctx, scanPath, existing, scanOpts) if err != nil { - log.Printf("[auto-scan] sync failed: %v", err) - return + log.Printf("[auto-scan] scan failed for %s: %v", scanPath, err) + continue + } + mergedItems = append(mergedItems, cache.Items...) + + items := library.BuildSyncItems(cache) + if len(items) == 0 { + log.Printf("[auto-scan] no items under %s", scanPath) + continue + } + + syncStartedAt := time.Now().UTC().Format(time.RFC3339) + for i := 0; i < len(items); i += batchSize { + end := i + batchSize + if end > len(items) { + end = len(items) + } + isLast := end >= len(items) + + _, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{ + Items: items[i:end], + ScanPath: scanPath, + IsLastBatch: isLast, + SyncStartedAt: syncStartedAt, + }) + if err != nil { + log.Printf("[auto-scan] sync failed for %s: %v", scanPath, err) + break + } + } + totalSynced += len(items) + } + + // Save merged cache for incremental scanning next time. + if len(mergedItems) > 0 { + mergedCache := &library.LibraryCache{ + ScannedAt: time.Now().UTC().Format(time.RFC3339), + Path: scanPaths[0], + Items: mergedItems, + } + if err := library.SaveCache(mergedCache); err != nil { + log.Printf("[auto-scan] save cache failed: %v", err) } } - log.Printf("[auto-scan] synced %d items", len(items)) + log.Printf("[auto-scan] synced %d items across %d path(s)", totalSynced, len(scanPaths)) } doScan() diff --git a/internal/cmd/scan.go b/internal/cmd/scan.go index 3633028..df66a18 100644 --- a/internal/cmd/scan.go +++ b/internal/cmd/scan.go @@ -41,11 +41,16 @@ to see available quality upgrades.`, } if len(args) == 0 { cfg := loadConfig() - if cfg.Library.ScanPath != "" { - args = append(args, cfg.Library.ScanPath) - } else { - return fmt.Errorf("usage: unarr scan \n\nProvide a media folder to scan") + paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath) + if len(paths) == 0 { + return fmt.Errorf("usage: unarr scan \n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'") } + for _, p := range paths { + if err := runScan(p, workers, ffprobe, noSync); err != nil { + return err + } + } + return nil } return runScan(args[0], workers, ffprobe, noSync) }, diff --git a/internal/library/paths.go b/internal/library/paths.go new file mode 100644 index 0000000..88752bf --- /dev/null +++ b/internal/library/paths.go @@ -0,0 +1,55 @@ +package library + +import ( + "path/filepath" + "strings" +) + +// ResolveScanPaths returns a deduplicated list of directories to scan. +// Always includes dlDir, moviesDir, tvDir (when non-empty). +// Adds scanPath if non-empty. +// Removes paths that are subdirectories of other paths in the list, +// since a parent walk already covers them. +func ResolveScanPaths(dlDir, moviesDir, tvDir, scanPath string) []string { + raw := make([]string, 0, 4) + for _, p := range []string{dlDir, moviesDir, tvDir, scanPath} { + if p != "" { + raw = append(raw, filepath.Clean(p)) + } + } + return deduplicatePaths(raw) +} + +// deduplicatePaths removes duplicate paths and paths that are subdirectories +// of another path already present in the list. +func deduplicatePaths(paths []string) []string { + // Remove exact duplicates first. + seen := make(map[string]bool, len(paths)) + unique := make([]string, 0, len(paths)) + for _, p := range paths { + if !seen[p] { + seen[p] = true + unique = append(unique, p) + } + } + + // Remove paths that are subdirs of another path in the list. + result := make([]string, 0, len(unique)) + for _, p := range unique { + isChild := false + for _, other := range unique { + if other == p { + continue + } + rel, err := filepath.Rel(other, p) + if err == nil && rel != "." && !strings.HasPrefix(rel, "..") { + isChild = true + break + } + } + if !isChild { + result = append(result, p) + } + } + return result +} From 8ad8a5ea470788ce04f8a4048b49dc4daab7db68 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 11:47:58 +0200 Subject: [PATCH 05/89] chore(release): 0.6.7 - Bump version to 0.6.7 - Update CHANGELOG.md --- CHANGELOG.md | 12 ++++++++++++ internal/cmd/version.go | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 96931f6..e5108f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,23 @@ 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.6.7] - 2026-04-10 + + +### Added + +- **scan**: always scan downloads + organize dirs, deduplicate child paths ## [0.6.6] - 2026-04-09 ### Fixed +- **docker**: switch ffprobe download from johnvansickle.com to BtbN/FFmpeg-Builds - **stream**: fix black screen on remote/Tailscale streaming + +### Other + +- **release**: 0.6.6 ## [0.6.5] - 2026-04-09 @@ -228,6 +239,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - remove UPX compression (antivirus false positives, startup penalty) - add -s -w -trimpath to Makefile, add build-small target with UPX +[0.6.7]: https://github.com/torrentclaw/unarr/compare/v0.6.6...v0.6.7 [0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6 [0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5 [0.6.4]: https://github.com/torrentclaw/unarr/compare/v0.6.3...v0.6.4 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 1669d95..fd83b6c 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.6.6" +var Version = "0.6.7" From f699b26fa687390b73ea98f6ad41c2d44c58e6bf Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 16:35:12 +0200 Subject: [PATCH 06/89] feat(library): add server-driven file deletion with allow_delete config --- internal/agent/daemon.go | 8 +- internal/agent/sync.go | 53 ++++ internal/agent/types.go | 47 ++-- internal/cmd/config_menu.go | 17 +- internal/cmd/daemon.go | 11 +- internal/config/config.go | 1 + internal/engine/stream_server.go | 69 ++++++ internal/library/delete.go | 148 +++++++++++ internal/library/delete_test.go | 414 +++++++++++++++++++++++++++++++ 9 files changed, 744 insertions(+), 24 deletions(-) create mode 100644 internal/library/delete.go create mode 100644 internal/library/delete_test.go diff --git a/internal/agent/daemon.go b/internal/agent/daemon.go index 225dde9..4e53c48 100644 --- a/internal/agent/daemon.go +++ b/internal/agent/daemon.go @@ -18,9 +18,11 @@ type DaemonConfig struct { AgentName string Version string DownloadDir string - StreamPort int // port for the HTTP stream server - LanIP string // LAN IP (reported in sync for stream URL resolution) - TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution) + StreamPort int // port for the HTTP stream server + LanIP string // LAN IP (reported in sync for stream URL resolution) + TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution) + CanDelete bool // library.allow_delete is enabled + ScanPaths []string // configured scan paths for file deletion validation } // Daemon manages agent registration and the sync loop. diff --git a/internal/agent/sync.go b/internal/agent/sync.go index 484472e..49f0e65 100644 --- a/internal/agent/sync.go +++ b/internal/agent/sync.go @@ -4,6 +4,7 @@ import ( "context" "log" "runtime" + "sync" "sync/atomic" "time" ) @@ -34,12 +35,22 @@ type SyncClient struct { OnSyncSuccess func() // called after each successful sync (e.g. to update state file) GetFreeSlots func() int GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks + // OnDeleteFiles is called when the server requests file deletion from disk. + // It should delete the files and return the IDs of successfully deleted items. + OnDeleteFiles func(items []LibraryDeleteRequest) []int // SyncNow triggers an immediate sync (e.g., on task completion). SyncNow chan struct{} watching atomic.Bool interval atomic.Int64 // stored as nanoseconds + + // pendingDeleteConfirmed holds item IDs to report as deleted in the next sync. + pendingDeleteMu sync.Mutex + pendingDeleteConfirmed []int + // deleteInFlight tracks item IDs currently being processed or awaiting confirmation. + // Prevents the same file from being passed to OnDeleteFiles multiple times. + deleteInFlight map[int]struct{} } // NewSyncClient creates a sync client. @@ -129,6 +140,7 @@ func (sc *SyncClient) buildRequest() SyncRequest { StreamPort: sc.cfg.StreamPort, LanIP: sc.cfg.LanIP, TailscaleIP: sc.cfg.TailscaleIP, + CanDelete: sc.cfg.CanDelete, } if sc.GetTaskStates != nil { req.Tasks = sc.GetTaskStates() @@ -142,6 +154,18 @@ func (sc *SyncClient) buildRequest() SyncRequest { if sc.GetFreeSlots != nil { req.FreeSlots = sc.GetFreeSlots() } + // Flush confirmed deletions from previous cycle. + // Once flushed, remove IDs from deleteInFlight — the server will stop sending + // them after this sync, so deduplication protection is no longer needed. + sc.pendingDeleteMu.Lock() + if len(sc.pendingDeleteConfirmed) > 0 { + req.DeleteConfirmed = sc.pendingDeleteConfirmed + for _, id := range sc.pendingDeleteConfirmed { + delete(sc.deleteInFlight, id) + } + sc.pendingDeleteConfirmed = nil + } + sc.pendingDeleteMu.Unlock() return req } @@ -176,6 +200,35 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) { if resp.Scan && sc.OnScan != nil { sc.OnScan() } + + // File deletions requested by the server — deduplicate against in-flight items + if len(resp.FilesToDelete) > 0 && sc.OnDeleteFiles != nil { + sc.pendingDeleteMu.Lock() + if sc.deleteInFlight == nil { + sc.deleteInFlight = make(map[int]struct{}) + } + var newItems []LibraryDeleteRequest + for _, item := range resp.FilesToDelete { + if _, inFlight := sc.deleteInFlight[item.ItemID]; !inFlight { + newItems = append(newItems, item) + sc.deleteInFlight[item.ItemID] = struct{}{} + } + } + sc.pendingDeleteMu.Unlock() + + if len(newItems) > 0 { + // Run deletions off the sync goroutine — disk I/O must not block the + // next sync tick. Confirmations are picked up on the next regular cycle. + go func(items []LibraryDeleteRequest) { + confirmed := sc.OnDeleteFiles(items) + if len(confirmed) > 0 { + sc.pendingDeleteMu.Lock() + sc.pendingDeleteConfirmed = append(sc.pendingDeleteConfirmed, confirmed...) + sc.pendingDeleteMu.Unlock() + } + }(newItems) + } + } } // runWakeListener holds a long-poll connection to /api/internal/agent/wake. diff --git a/internal/agent/types.go b/internal/agent/types.go index e7d07d6..16ba92a 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -312,19 +312,21 @@ type LibrarySyncResponse struct { // SyncRequest is sent by the CLI periodically to synchronize state with the server. // Contains the CLI's full execution state — the server responds with pending actions. type SyncRequest struct { - AgentID string `json:"agentId"` - Version string `json:"version,omitempty"` - OS string `json:"os,omitempty"` - Arch string `json:"arch,omitempty"` - Name string `json:"name,omitempty"` - DownloadDir string `json:"downloadDir,omitempty"` - DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"` - DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"` - StreamPort int `json:"streamPort,omitempty"` - LanIP string `json:"lanIp,omitempty"` - TailscaleIP string `json:"tailscaleIp,omitempty"` - FreeSlots int `json:"freeSlots"` - Tasks []TaskState `json:"tasks"` + AgentID string `json:"agentId"` + Version string `json:"version,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + Name string `json:"name,omitempty"` + DownloadDir string `json:"downloadDir,omitempty"` + DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"` + DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"` + StreamPort int `json:"streamPort,omitempty"` + LanIP string `json:"lanIp,omitempty"` + TailscaleIP string `json:"tailscaleIp,omitempty"` + FreeSlots int `json:"freeSlots"` + Tasks []TaskState `json:"tasks"` + CanDelete bool `json:"canDelete"` // library.allow_delete is enabled + DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk } // ControlAction represents a server-side control signal for a task. @@ -334,14 +336,21 @@ type ControlAction struct { DeleteFiles bool `json:"deleteFiles,omitempty"` } +// LibraryDeleteRequest is a server-side request to delete a file from disk. +type LibraryDeleteRequest struct { + ItemID int `json:"itemId"` + FilePath string `json:"filePath"` +} + // SyncResponse is returned by the server with all pending actions for the CLI. type SyncResponse struct { - NewTasks []Task `json:"newTasks,omitempty"` - Controls []ControlAction `json:"controls,omitempty"` - StreamRequests []StreamRequest `json:"streamRequests,omitempty"` - Watching bool `json:"watching"` - Upgrade *UpgradeSignal `json:"upgrade,omitempty"` - Scan bool `json:"scan,omitempty"` + NewTasks []Task `json:"newTasks,omitempty"` + Controls []ControlAction `json:"controls,omitempty"` + StreamRequests []StreamRequest `json:"streamRequests,omitempty"` + Watching bool `json:"watching"` + Upgrade *UpgradeSignal `json:"upgrade,omitempty"` + Scan bool `json:"scan,omitempty"` + FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"` } // --------------------------------------------------------------------------- diff --git a/internal/cmd/config_menu.go b/internal/cmd/config_menu.go index 9b1ddbf..334d815 100644 --- a/internal/cmd/config_menu.go +++ b/internal/cmd/config_menu.go @@ -14,7 +14,7 @@ import ( "github.com/torrentclaw/unarr/internal/config" ) -var configCategories = []string{"downloads", "organization", "notifications", "device", "region", "connection", "advanced"} +var configCategories = []string{"downloads", "organization", "library", "notifications", "device", "region", "connection", "advanced"} func newConfigCmd() *cobra.Command { cmd := &cobra.Command{ @@ -25,6 +25,7 @@ func newConfigCmd() *cobra.Command { Categories: downloads Download directory, method, speed limits, concurrency organization Auto-sort into Movies / TV Shows folders + library Library scan settings and file deletion permissions notifications Desktop notifications device Agent name region Country and language @@ -95,6 +96,7 @@ func runConfigMenu(category string) error { Options( huh.NewOption("Downloads — directory, method, speed limits", "downloads"), huh.NewOption("Organization — auto-sort Movies & TV Shows", "organization"), + huh.NewOption("Library — scan settings & file deletion", "library"), huh.NewOption("Notifications — desktop notifications", "notifications"), huh.NewOption("Device — agent name", "device"), huh.NewOption("Region — country & language", "region"), @@ -131,6 +133,8 @@ func runCategory(cfg *config.Config, category string) error { return configDownloads(cfg) case "organization": return configOrganization(cfg) + case "library": + return configLibrary(cfg) case "notifications": return configNotifications(cfg) case "device": @@ -311,6 +315,17 @@ func configConnection(cfg *config.Config) error { ).Run() } +func configLibrary(cfg *config.Config) error { + return huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Allow file deletion from web UI?"). + Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered."). + Value(&cfg.Library.AllowDelete), + ), + ).Run() +} + func configAdvanced(_ *config.Config) error { // Sync intervals are adaptive (3s watching, 60s idle) — no user-facing config needed. fmt.Println("No advanced settings to configure. Sync intervals are automatic.") diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index e4abcc6..b6fb402 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -138,6 +138,8 @@ func runDaemonStart() error { StreamPort: cfg.Download.StreamPort, LanIP: engine.LanIP(), TailscaleIP: engine.TailscaleIP(), + CanDelete: cfg.Library.AllowDelete, + ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath), } // Create HTTP client — single communication channel @@ -302,6 +304,13 @@ func runDaemonStart() error { } } + // Wire: sync receives file deletion requests from the server + if cfg.Library.AllowDelete && len(daemonCfg.ScanPaths) > 0 { + sc.OnDeleteFiles = func(items []agent.LibraryDeleteRequest) []int { + return library.DeleteFiles(items, daemonCfg.ScanPaths) + } + } + // Wire: sync receives stream requests for completed downloads d.OnStreamRequested = func(sr agent.StreamRequest) { if streamSrv.CurrentTaskID() == sr.TaskID { @@ -401,7 +410,7 @@ func runDaemonStart() error { }() // Start auto-scan goroutine - scanPaths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath) + scanPaths := daemonCfg.ScanPaths if len(scanPaths) > 0 && cfg.Library.AutoScan { scanInterval := 24 * time.Hour if cfg.Library.ScanInterval != "" { diff --git a/internal/config/config.go b/internal/config/config.go index cba221c..5c593d5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -73,6 +73,7 @@ type LibraryConfig struct { BackupDir string `toml:"backup_dir"` // for replaced files AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true) ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h") + AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk } // Default returns a Config with sensible defaults. diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 359d0b1..2a6c72f 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -72,6 +72,7 @@ func (ss *StreamServer) Listen(ctx context.Context) error { mux := http.NewServeMux() mux.HandleFunc("/stream", ss.handler) mux.HandleFunc("/health", ss.healthHandler) + mux.HandleFunc("/playlist.m3u", ss.playlistHandler) // SO_REUSEADDR allows immediate rebind if the port is in TIME_WAIT (e.g. after agent restart) lc := net.ListenConfig{ @@ -274,6 +275,74 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(resp) //nolint:errcheck } +// playlistHandler generates an M3U playlist for VLC with #EXTVLCOPT language hints. +// Query params: audioLangs (comma-sep), subLangs (comma-sep), resumeSec, title, streamUrl. +// If streamUrl is omitted, uses the current best stream URL. +// +// VLC fetches this playlist and applies the EXTVLCOPT directives automatically, +// enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile). +func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) { + // CORS — handle preflight before doing any work (consistent with handler) + if origin := r.Header.Get("Origin"); origin != "" { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Range") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + } + + q := r.URL.Query() + + // Sanitize query params: strip CR/LF to prevent M3U directive injection. + sanitize := func(s string) string { + s = strings.ReplaceAll(s, "\n", "") + s = strings.ReplaceAll(s, "\r", "") + return s + } + + audioLangs := sanitize(q.Get("audioLangs")) + subLangs := sanitize(q.Get("subLangs")) + resumeSec := sanitize(q.Get("resumeSec")) + title := sanitize(q.Get("title")) + streamURL := q.Get("streamUrl") + // Only accept http(s) URLs to prevent file:// or other URI schemes in the playlist. + if streamURL != "" && !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") { + streamURL = "" + } + if streamURL == "" { + streamURL = ss.url + } + if streamURL == "" { + http.Error(w, "no active stream", http.StatusNotFound) + return + } + if title == "" { + title = "TorrentClaw Stream" + } + + var b strings.Builder + b.WriteString("#EXTM3U\n") + b.WriteString(fmt.Sprintf("#EXTINF:-1,%s\n", title)) + if audioLangs != "" { + b.WriteString(fmt.Sprintf("#EXTVLCOPT:audio-language=%s\n", audioLangs)) + } + if subLangs != "" { + b.WriteString(fmt.Sprintf("#EXTVLCOPT:sub-language=%s\n", subLangs)) + } + if resumeSec != "" && resumeSec != "0" { + b.WriteString(fmt.Sprintf("#EXTVLCOPT:start-time=%s\n", resumeSec)) + } + b.WriteString("#EXTVLCOPT:network-caching=30000\n") + b.WriteString(streamURL + "\n") + + w.Header().Set("Content-Type", "audio/x-mpegurl") + w.Header().Set("Content-Disposition", `inline; filename="stream.m3u"`) + w.Header().Set("Cache-Control", "no-cache") + fmt.Fprint(w, b.String()) //nolint:errcheck +} + func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) { ss.lastActivity.Store(time.Now().UnixNano()) diff --git a/internal/library/delete.go b/internal/library/delete.go new file mode 100644 index 0000000..3920c6e --- /dev/null +++ b/internal/library/delete.go @@ -0,0 +1,148 @@ +package library + +import ( + "fmt" + "log" + "os" + "path/filepath" + "strings" + + "github.com/torrentclaw/unarr/internal/agent" +) + +// DeleteFiles deletes the given library items from disk and cleans up empty +// parent directories within the configured scan paths. +// +// Safety rules (all must pass before os.Remove is called): +// 1. filePath must be an absolute path. +// 2. filePath must be within one of the configured scanPaths. +// 3. Empty parent directories are removed up to (but not including) the +// scan path root and only if they are not the scan path itself. +// +// Returns the IDs of items successfully deleted. +func DeleteFiles(items []agent.LibraryDeleteRequest, scanPaths []string) []int { + // Sanitize scan paths: reject empty or non-absolute entries. + safe := make([]string, 0, len(scanPaths)) + for _, sp := range scanPaths { + if filepath.IsAbs(sp) { + safe = append(safe, sp) + } else { + log.Printf("library: ignoring non-absolute scan path: %q", sp) + } + } + if len(safe) == 0 { + log.Printf("library: no valid scan paths configured — refusing to delete") + return nil + } + + confirmed := make([]int, 0, len(items)) + + for _, item := range items { + if err := deleteOne(item.FilePath, safe); err != nil { + log.Printf("library: delete item %d (%q): %v", item.ItemID, item.FilePath, err) + continue + } + log.Printf("library: deleted item %d: %s", item.ItemID, item.FilePath) + confirmed = append(confirmed, item.ItemID) + } + + return confirmed +} + +func deleteOne(filePath string, scanPaths []string) error { + if !filepath.IsAbs(filePath) { + return fmt.Errorf("path is not absolute: %q", filePath) + } + + clean := filepath.Clean(filePath) + + // Resolve symlinks before validation to prevent traversal via symlinks. + real, err := filepath.EvalSymlinks(clean) + if err != nil { + if os.IsNotExist(err) { + // File already gone — idempotent success. + pruneEmptyDirs(filepath.Dir(clean), scanPaths) + return nil + } + return fmt.Errorf("resolve symlinks: %w", err) + } + + // Security: resolved file must be within one of the configured scan paths. + if !isWithinScanPaths(real, scanPaths) { + return fmt.Errorf("path %q (resolved: %q) is outside all configured scan paths — refusing to delete", clean, real) + } + + // Remove the file (idempotent: not-exist is not an error). + if err := os.Remove(real); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove file: %w", err) + } + + // Clean up empty parent directories, stopping at the scan path root. + pruneEmptyDirs(filepath.Dir(real), scanPaths) + + return nil +} + +// isWithinScanPaths returns true if p is a child of any scan path. +func isWithinScanPaths(p string, scanPaths []string) bool { + for _, sp := range scanPaths { + sp = filepath.Clean(sp) + rel, err := filepath.Rel(sp, p) + if err != nil { + continue + } + // rel must not be "." (exact match = root itself) and must not start with ".." + if rel != "." && !strings.HasPrefix(rel, "..") { + return true + } + } + return false +} + +// pruneEmptyDirs walks upward from dir, removing empty directories until it +// reaches a scan path root (which is never removed). +// Max 10 levels to guard against infinite loops on unexpected path shapes. +func pruneEmptyDirs(dir string, scanPaths []string) { + const maxLevels = 10 + for i := 0; i < maxLevels; i++ { + dir = filepath.Clean(dir) + + // Single pass: stop if dir is a scan root or outside all scan paths. + if !dirEligibleForPrune(dir, scanPaths) { + return + } + + entries, err := os.ReadDir(dir) + if err != nil || len(entries) > 0 { + return // non-empty or unreadable — stop + } + + if err := os.Remove(dir); err != nil { + log.Printf("library: prune dir %s: %v", dir, err) + return + } + log.Printf("library: removed empty dir: %s", dir) + + dir = filepath.Dir(dir) + } +} + +// dirEligibleForPrune returns true if dir is a strict child of any scan path +// (i.e. it is inside a scan path but is not the scan root itself). +// Combines the former isScanPathRoot + isWithinScanPaths checks into one loop. +func dirEligibleForPrune(dir string, scanPaths []string) bool { + for _, sp := range scanPaths { + sp = filepath.Clean(sp) + if sp == dir { + return false // dir IS the scan root — never remove it + } + rel, err := filepath.Rel(sp, dir) + if err != nil { + continue + } + if rel != "." && !strings.HasPrefix(rel, "..") { + return true + } + } + return false +} diff --git a/internal/library/delete_test.go b/internal/library/delete_test.go new file mode 100644 index 0000000..6b64142 --- /dev/null +++ b/internal/library/delete_test.go @@ -0,0 +1,414 @@ +package library + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/torrentclaw/unarr/internal/agent" +) + +// --------------------------------------------------------------------------- +// isWithinScanPaths +// --------------------------------------------------------------------------- + +func TestIsWithinScanPaths(t *testing.T) { + tests := []struct { + name string + path string + scanPaths []string + want bool + }{ + { + name: "file inside scan path", + path: "/media/movies/Inception.mkv", + scanPaths: []string{"/media/movies"}, + want: true, + }, + { + name: "file in subdirectory of scan path", + path: "/media/movies/2024/Inception/Inception.mkv", + scanPaths: []string{"/media/movies"}, + want: true, + }, + { + name: "file at scan path root itself", + path: "/media/movies", + scanPaths: []string{"/media/movies"}, + want: false, // rel == "." + }, + { + name: "file outside all scan paths", + path: "/tmp/evil.mkv", + scanPaths: []string{"/media/movies", "/media/shows"}, + want: false, + }, + { + name: "dotdot traversal attempt", + path: "/media/movies/../../../etc/passwd", + scanPaths: []string{"/media/movies"}, + want: false, + }, + { + name: "multiple scan paths file in second", + path: "/media/shows/Breaking.Bad.S01E01.mkv", + scanPaths: []string{"/media/movies", "/media/shows"}, + want: true, + }, + { + name: "empty scan paths", + path: "/media/movies/file.mkv", + scanPaths: []string{}, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isWithinScanPaths(tt.path, tt.scanPaths) + if got != tt.want { + t.Errorf("isWithinScanPaths(%q, %v) = %v, want %v", tt.path, tt.scanPaths, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// dirEligibleForPrune +// --------------------------------------------------------------------------- + +func TestDirEligibleForPrune(t *testing.T) { + tests := []struct { + name string + dir string + scanPaths []string + want bool + }{ + { + name: "scan root itself is NOT eligible", + dir: "/media/movies", + scanPaths: []string{"/media/movies"}, + want: false, + }, + { + name: "subdirectory IS eligible", + dir: "/media/movies/2024", + scanPaths: []string{"/media/movies"}, + want: true, + }, + { + name: "parent of scan path is NOT eligible", + dir: "/media", + scanPaths: []string{"/media/movies"}, + want: false, + }, + { + name: "trailing slash normalization — root not eligible", + dir: "/media/movies", + scanPaths: []string{"/media/movies/"}, + want: false, // filepath.Clean removes trailing slash + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := dirEligibleForPrune(tt.dir, tt.scanPaths) + if got != tt.want { + t.Errorf("dirEligibleForPrune(%q, %v) = %v, want %v", tt.dir, tt.scanPaths, got, tt.want) + } + }) + } +} + +// --------------------------------------------------------------------------- +// deleteOne +// --------------------------------------------------------------------------- + +func TestDeleteOne(t *testing.T) { + t.Run("delete existing file inside scan path", func(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "movie.mkv") + if err := os.WriteFile(file, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + if err := deleteOne(file, []string{root}); err != nil { + t.Fatalf("deleteOne returned error: %v", err) + } + + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Error("file should have been deleted") + } + }) + + t.Run("reject relative path", func(t *testing.T) { + root := t.TempDir() + err := deleteOne("relative/path.mkv", []string{root}) + if err == nil { + t.Fatal("expected error for relative path") + } + if got := err.Error(); got != `path is not absolute: "relative/path.mkv"` { + t.Errorf("unexpected error message: %s", got) + } + }) + + t.Run("reject path outside scan paths", func(t *testing.T) { + scanRoot := t.TempDir() + outsideDir := t.TempDir() + file := filepath.Join(outsideDir, "secret.txt") + if err := os.WriteFile(file, []byte("secret"), 0644); err != nil { + t.Fatal(err) + } + + err := deleteOne(file, []string{scanRoot}) + if err == nil { + t.Fatal("expected error for path outside scan paths") + } + + // File must NOT have been deleted. + if _, statErr := os.Stat(file); statErr != nil { + t.Error("file outside scan path should NOT have been deleted") + } + }) + + t.Run("file already deleted is idempotent", func(t *testing.T) { + root := t.TempDir() + // Reference a file that does not exist. + file := filepath.Join(root, "gone.mkv") + + if err := deleteOne(file, []string{root}); err != nil { + t.Fatalf("expected idempotent success, got error: %v", err) + } + }) + + t.Run("symlink pointing outside scan path is rejected", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated privileges on Windows") + } + + scanRoot := t.TempDir() + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "real.mkv") + if err := os.WriteFile(outsideFile, []byte("real"), 0644); err != nil { + t.Fatal(err) + } + + link := filepath.Join(scanRoot, "link.mkv") + if err := os.Symlink(outsideFile, link); err != nil { + t.Fatal(err) + } + + err := deleteOne(link, []string{scanRoot}) + if err == nil { + t.Fatal("expected error: symlink target is outside scan paths") + } + + // The real file must NOT have been deleted. + if _, statErr := os.Stat(outsideFile); statErr != nil { + t.Error("symlink target outside scan path should NOT have been deleted") + } + }) + + t.Run("symlink pointing inside scan path is allowed", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlinks require elevated privileges on Windows") + } + + scanRoot := t.TempDir() + subdir := filepath.Join(scanRoot, "sub") + if err := os.Mkdir(subdir, 0755); err != nil { + t.Fatal(err) + } + realFile := filepath.Join(subdir, "real.mkv") + if err := os.WriteFile(realFile, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + link := filepath.Join(scanRoot, "link.mkv") + if err := os.Symlink(realFile, link); err != nil { + t.Fatal(err) + } + + if err := deleteOne(link, []string{scanRoot}); err != nil { + t.Fatalf("deleteOne returned error: %v", err) + } + + // The real file should have been deleted (os.Remove on resolved path). + if _, statErr := os.Stat(realFile); !os.IsNotExist(statErr) { + t.Error("resolved target inside scan path should have been deleted") + } + }) +} + +// --------------------------------------------------------------------------- +// pruneEmptyDirs +// --------------------------------------------------------------------------- + +func TestPruneEmptyDirs(t *testing.T) { + t.Run("empty parent dir is removed", func(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "show") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatal(err) + } + + pruneEmptyDirs(sub, []string{root}) + + if _, err := os.Stat(sub); !os.IsNotExist(err) { + t.Error("empty subdirectory should have been removed") + } + // Scan root must still exist. + if _, err := os.Stat(root); err != nil { + t.Error("scan path root should NOT have been removed") + } + }) + + t.Run("non-empty parent dir is NOT removed", func(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "show") + if err := os.Mkdir(sub, 0755); err != nil { + t.Fatal(err) + } + // Put a file inside so it's not empty. + if err := os.WriteFile(filepath.Join(sub, "keep.txt"), []byte("x"), 0644); err != nil { + t.Fatal(err) + } + + pruneEmptyDirs(sub, []string{root}) + + if _, err := os.Stat(sub); err != nil { + t.Error("non-empty directory should NOT have been removed") + } + }) + + t.Run("stops at scan path root", func(t *testing.T) { + root := t.TempDir() + // Create an empty dir that IS the scan root. + // pruneEmptyDirs should refuse to remove it. + pruneEmptyDirs(root, []string{root}) + + if _, err := os.Stat(root); err != nil { + t.Error("scan path root should never be removed") + } + }) + + t.Run("multi-level cleanup", func(t *testing.T) { + root := t.TempDir() + deep := filepath.Join(root, "a", "b", "c") + if err := os.MkdirAll(deep, 0755); err != nil { + t.Fatal(err) + } + + pruneEmptyDirs(deep, []string{root}) + + // All three levels (a, a/b, a/b/c) should be removed. + for _, dir := range []string{ + filepath.Join(root, "a", "b", "c"), + filepath.Join(root, "a", "b"), + filepath.Join(root, "a"), + } { + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Errorf("directory should have been removed: %s", dir) + } + } + + // Scan root must still exist. + if _, err := os.Stat(root); err != nil { + t.Error("scan path root should NOT have been removed") + } + }) +} + +// --------------------------------------------------------------------------- +// DeleteFiles (integration) +// --------------------------------------------------------------------------- + +func TestDeleteFiles(t *testing.T) { + t.Run("multiple items some valid some invalid", func(t *testing.T) { + root := t.TempDir() + outsideDir := t.TempDir() + goodFile := filepath.Join(root, "good.mkv") + if err := os.WriteFile(goodFile, []byte("ok"), 0644); err != nil { + t.Fatal(err) + } + outsideFile := filepath.Join(outsideDir, "outside.mkv") + if err := os.WriteFile(outsideFile, []byte("nope"), 0644); err != nil { + t.Fatal(err) + } + + items := []agent.LibraryDeleteRequest{ + {ItemID: 1, FilePath: goodFile}, // valid → deleted + {ItemID: 2, FilePath: "relative/bad.mkv"}, // relative → rejected + {ItemID: 3, FilePath: outsideFile}, // outside scan paths → rejected + {ItemID: 4, FilePath: filepath.Join(root, "gone.mkv")}, // not-exist → idempotent success + } + + confirmed := DeleteFiles(items, []string{root}) + + // Items 1 and 4 should succeed. Item 2 (relative) and 3 (outside) should fail. + want := map[int]bool{1: true, 4: true} + got := make(map[int]bool, len(confirmed)) + for _, id := range confirmed { + got[id] = true + } + if len(got) != len(want) { + t.Fatalf("confirmed = %v, want IDs %v", confirmed, want) + } + for id := range want { + if !got[id] { + t.Errorf("expected item %d to be confirmed", id) + } + } + + // outsideFile must NOT have been deleted. + if _, err := os.Stat(outsideFile); err != nil { + t.Error("file outside scan paths should NOT have been deleted") + } + + // good.mkv should be deleted. + if _, err := os.Stat(goodFile); !os.IsNotExist(err) { + t.Error("good.mkv should have been deleted") + } + }) + + t.Run("empty scan paths returns nil", func(t *testing.T) { + items := []agent.LibraryDeleteRequest{ + {ItemID: 1, FilePath: "/some/file.mkv"}, + } + confirmed := DeleteFiles(items, []string{}) + if confirmed != nil { + t.Errorf("expected nil, got %v", confirmed) + } + }) + + t.Run("all relative scan paths returns nil", func(t *testing.T) { + items := []agent.LibraryDeleteRequest{ + {ItemID: 1, FilePath: "/some/file.mkv"}, + } + confirmed := DeleteFiles(items, []string{"relative/path", "another/relative"}) + if confirmed != nil { + t.Errorf("expected nil, got %v", confirmed) + } + }) + + t.Run("mixed absolute and relative scan paths uses only absolute", func(t *testing.T) { + root := t.TempDir() + file := filepath.Join(root, "movie.mkv") + if err := os.WriteFile(file, []byte("data"), 0644); err != nil { + t.Fatal(err) + } + + items := []agent.LibraryDeleteRequest{ + {ItemID: 10, FilePath: file}, + } + confirmed := DeleteFiles(items, []string{"relative/bad", root}) + + if len(confirmed) != 1 || confirmed[0] != 10 { + t.Errorf("confirmed = %v, want [10]", confirmed) + } + if _, err := os.Stat(file); !os.IsNotExist(err) { + t.Error("file should have been deleted via the absolute scan path") + } + }) +} From debf77005f861f9a0719dcf61ef4574cf66bb9a5 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 16:36:27 +0200 Subject: [PATCH 07/89] chore(release): 0.6.8 - Bump version to 0.6.8 - 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 e5108f0..211ebf8 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.6.8] - 2026-04-10 + + +### Added + +- **library**: add server-driven file deletion with allow_delete config ## [0.6.7] - 2026-04-10 ### Added - **scan**: always scan downloads + organize dirs, deduplicate child paths + +### Other + +- **release**: 0.6.7 ## [0.6.6] - 2026-04-09 @@ -239,6 +249,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - remove UPX compression (antivirus false positives, startup penalty) - add -s -w -trimpath to Makefile, add build-small target with UPX +[0.6.8]: https://github.com/torrentclaw/unarr/compare/v0.6.7...v0.6.8 [0.6.7]: https://github.com/torrentclaw/unarr/compare/v0.6.6...v0.6.7 [0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6 [0.6.5]: https://github.com/torrentclaw/unarr/compare/v0.6.4...v0.6.5 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index fd83b6c..68d857f 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.6.7" +var Version = "0.6.8" From 37fcb9fad94fc6f251f059b374d3c4f21d51423f Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 19:18:13 +0200 Subject: [PATCH 08/89] feat(daemon): enhance service management with start, stop, restart, and status commands for Windows --- internal/cmd/daemon.go | 38 ++-- internal/cmd/daemon_control.go | 331 +++++++++++++++++++++++++++++++++ internal/cmd/daemon_install.go | 59 ++++++ internal/cmd/reload_unix.go | 36 ++++ internal/cmd/reload_windows.go | 32 +++- 5 files changed, 479 insertions(+), 17 deletions(-) create mode 100644 internal/cmd/daemon_control.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index b6fb402..b8db356 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -46,27 +46,20 @@ To run as a background service, use 'unarr daemon install' instead.`, } } -// newStopCmd creates the top-level `unarr stop` placeholder. +// newStopCmd creates the top-level `unarr stop` command. func newStopCmd() *cobra.Command { return &cobra.Command{ Use: "stop", Short: "Stop the running daemon", - Long: `Stop the unarr daemon. + Long: `Stop the unarr daemon gracefully. -If running in the foreground, press Ctrl+C in the terminal where it was started. -If installed as a system service, use your OS service manager: +Reads the daemon PID from the state file and sends a graceful stop signal. +Works regardless of whether the daemon was started in the foreground or as a service. - Linux (systemd): systemctl --user stop unarr - macOS (launchd): launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist`, +To stop a service-managed daemon and prevent auto-restart, use 'unarr daemon stop' instead.`, Example: ` unarr stop`, RunE: func(cmd *cobra.Command, args []string) error { - fmt.Println(" Use Ctrl+C in the terminal where the daemon is running.") - fmt.Println() - fmt.Println(" If installed as a service:") - fmt.Println(" Linux: systemctl --user stop unarr") - fmt.Println(" macOS: launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist") - fmt.Println() - return nil + return stopDaemonByPID() }, } } @@ -76,17 +69,30 @@ func newDaemonCmd() *cobra.Command { cmd := &cobra.Command{ Use: "daemon ", Short: "Manage the daemon as a system service", - Long: `Install or remove unarr as a system service that starts automatically on boot. + Long: `Install, control and inspect the unarr daemon as a system service. - Linux: Creates a systemd user service (~/.config/systemd/user/unarr.service) - macOS: Creates a launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist)`, + Linux: systemd user service (~/.config/systemd/user/unarr.service) + macOS: launchd agent (~/Library/LaunchAgents/com.torrentclaw.unarr.plist) + Windows: Task Scheduler task (runs at logon)`, Example: ` unarr daemon install + unarr daemon start + unarr daemon status + unarr daemon logs -f + unarr daemon reload + unarr daemon restart + unarr daemon stop unarr daemon uninstall`, } cmd.AddCommand( newDaemonInstallCmdReal(), newDaemonUninstallCmdReal(), + newDaemonStartCmd(), + newDaemonStopCmd(), + newDaemonRestartCmd(), + newDaemonSvcStatusCmd(), + newDaemonLogsCmd(), + newDaemonReloadCmd(), ) return cmd diff --git a/internal/cmd/daemon_control.go b/internal/cmd/daemon_control.go new file mode 100644 index 0000000..558fb26 --- /dev/null +++ b/internal/cmd/daemon_control.go @@ -0,0 +1,331 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/torrentclaw/unarr/internal/agent" + "github.com/torrentclaw/unarr/internal/config" +) + +func newDaemonStartCmd() *cobra.Command { + return &cobra.Command{ + Use: "start", + Short: "Start the installed daemon service", + Long: `Start the unarr daemon using the system service manager. +Requires 'unarr daemon install' to have been run first. + + Linux: systemctl --user start unarr + macOS: launchctl load ~/Library/LaunchAgents/com.torrentclaw.unarr.plist + Windows: schtasks /run /tn unarr`, + Example: ` unarr daemon start`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonSvcStart() + }, + } +} + +func newDaemonStopCmd() *cobra.Command { + return &cobra.Command{ + Use: "stop", + Short: "Stop the running daemon service", + Long: `Stop the unarr daemon service. + + Linux: systemctl --user stop unarr + macOS: launchctl unload ~/Library/LaunchAgents/com.torrentclaw.unarr.plist + Windows: sends stop signal via process PID`, + Example: ` unarr daemon stop`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonSvcStop() + }, + } +} + +func newDaemonRestartCmd() *cobra.Command { + return &cobra.Command{ + Use: "restart", + Short: "Restart the daemon service", + Long: `Restart the unarr daemon service. + + Linux: systemctl --user restart unarr + macOS: unload + reload launchd agent + Windows: stop by PID + schtasks /run`, + Example: ` unarr daemon restart`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonSvcRestart() + }, + } +} + +func newDaemonSvcStatusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show daemon service status", + Long: `Show the current status of the unarr daemon service as reported +by the system service manager, plus local state information.`, + Example: ` unarr daemon status`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonSvcStatus() + }, + } +} + +func newDaemonLogsCmd() *cobra.Command { + var follow bool + var lines int + + cmd := &cobra.Command{ + Use: "logs", + Short: "Show daemon logs", + Long: `Show daemon log output. + + Linux: streams from journald (journalctl --user -u unarr) + macOS: tails ~/.local/share/unarr/unarr.log + Windows: tails %LOCALAPPDATA%\unarr\unarr.log`, + Example: ` unarr daemon logs + unarr daemon logs -f + unarr daemon logs -n 100 -f`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonLogs(follow, lines) + }, + } + + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") + cmd.Flags().IntVarP(&lines, "lines", "n", 50, "Number of lines to show") + return cmd +} + +func newDaemonReloadCmd() *cobra.Command { + return &cobra.Command{ + Use: "reload", + Short: "Reload daemon configuration without restarting", + Long: `Send a reload signal to the running daemon, causing it to +re-read its configuration file without interrupting active downloads. + + Linux/macOS: sends SIGUSR1 to the daemon process + Windows: not supported (use 'unarr daemon restart' instead)`, + Example: ` unarr daemon reload`, + RunE: func(cmd *cobra.Command, args []string) error { + return runDaemonReload() + }, + } +} + +// ── Platform implementations ────────────────────────────────────────────────── + +func runDaemonSvcStart() error { + fmt.Println() + switch runtime.GOOS { + case "linux": + if err := svcExec("systemctl", "--user", "start", "unarr"); err != nil { + fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.") + return fmt.Errorf("start service: %w", err) + } + case "darwin": + home, _ := os.UserHomeDir() + plist := launchdPlistPath(home) + if _, err := os.Stat(plist); err != nil { + return fmt.Errorf("service not installed — run 'unarr daemon install' first") + } + if err := svcExec("launchctl", "load", plist); err != nil { + return fmt.Errorf("load service: %w", err) + } + case "windows": + if err := svcExec("schtasks", "/run", "/tn", "unarr"); err != nil { + fmt.Fprintln(os.Stderr, "\n Is the daemon installed? Run 'unarr daemon install' first.") + return fmt.Errorf("start task: %w", err) + } + default: + return fmt.Errorf("service control not supported on %s", runtime.GOOS) + } + + color.New(color.FgGreen).Println(" ✓ Started") + fmt.Println() + return nil +} + +func runDaemonSvcStop() error { + fmt.Println() + switch runtime.GOOS { + case "linux": + if err := svcExec("systemctl", "--user", "stop", "unarr"); err != nil { + return fmt.Errorf("stop service: %w", err) + } + case "darwin": + home, _ := os.UserHomeDir() + plist := launchdPlistPath(home) + if err := svcExec("launchctl", "unload", plist); err != nil { + return fmt.Errorf("unload service: %w", err) + } + default: + return stopDaemonByPID() + } + + color.New(color.FgGreen).Println(" ✓ Stopped") + fmt.Println() + return nil +} + +func runDaemonSvcRestart() error { + switch runtime.GOOS { + case "linux": + fmt.Println() + if err := svcExec("systemctl", "--user", "restart", "unarr"); err != nil { + return fmt.Errorf("restart service: %w", err) + } + color.New(color.FgGreen).Println(" ✓ Restarted") + fmt.Println() + return nil + default: + fmt.Println(" Stopping...") + _ = runDaemonSvcStop() + fmt.Println(" Starting...") + return runDaemonSvcStart() + } +} + +func runDaemonSvcStatus() error { + fmt.Println() + switch runtime.GOOS { + case "linux": + // systemctl gives rich formatted output; exit code non-zero when stopped is fine. + svcExec("systemctl", "--user", "status", "--no-pager", "unarr") //nolint:errcheck + case "darwin": + printDaemonStatusDarwin() + case "windows": + svcExec("schtasks", "/query", "/tn", "unarr", "/fo", "LIST") //nolint:errcheck + default: + fmt.Printf(" Service manager not supported on %s\n", runtime.GOOS) + } + + printStateInfo() + return nil +} + +func runDaemonLogs(follow bool, lines int) error { + switch runtime.GOOS { + case "linux": + args := []string{"--user", "-u", "unarr", "--no-pager", "-n", strconv.Itoa(lines)} + if follow { + // -f implies live output; drop --no-pager so journalctl can control the terminal. + args = []string{"--user", "-u", "unarr", "-f"} + } + return svcExecInteractive("journalctl", args...) + + case "darwin": + home, _ := os.UserHomeDir() + logFile := filepath.Join(home, ".local", "share", "unarr", "unarr.log") + if _, err := os.Stat(logFile); err != nil { + fmt.Fprintln(os.Stderr, "The daemon writes this file when running as a launchd service. Run 'unarr daemon install' first.") + return fmt.Errorf("log file not found: %s", logFile) + } + args := []string{"-n", strconv.Itoa(lines)} + if follow { + args = append(args, "-f") + } + args = append(args, logFile) + return svcExecInteractive("tail", args...) + + case "windows": + logFile := filepath.Join(config.DataDir(), "unarr.log") + if _, err := os.Stat(logFile); err != nil { + fmt.Fprintln(os.Stderr, "The daemon writes logs here when running. Start it first.") + return fmt.Errorf("log file not found: %s", logFile) + } + var psCmd string + if follow { + psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d -Wait", logFile, lines) + } else { + psCmd = fmt.Sprintf("Get-Content -Path '%s' -Tail %d", logFile, lines) + } + return svcExecInteractive("powershell", "-NonInteractive", "-Command", psCmd) + + default: + return fmt.Errorf("log viewing not supported on %s", runtime.GOOS) + } +} + +func runDaemonReload() error { + return sendReloadSignal() +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID. +// Used as fallback on platforms without a service manager (and as Windows implementation). +func stopDaemonByPID() error { + state := agent.ReadState() + if state == nil { + return fmt.Errorf("daemon does not appear to be running (state file not found)") + } + return killPID(state.PID) +} + +func launchdPlistPath(home string) string { + return filepath.Join(home, "Library", "LaunchAgents", "com.torrentclaw.unarr.plist") +} + +// printDaemonStatusDarwin shows launchd service state by filtering launchctl output. +func printDaemonStatusDarwin() { + out, err := exec.Command("launchctl", "list").Output() + if err != nil { + fmt.Printf(" Could not query launchctl: %v\n", err) + return + } + found := false + for _, line := range strings.Split(string(out), "\n") { + if strings.Contains(line, "unarr") { + // Format: PID ExitCode Label + fmt.Printf(" launchd: %s\n", strings.TrimSpace(line)) + found = true + } + } + if !found { + fmt.Println(" launchd: service not loaded") + } +} + +// printStateInfo shows information from the local daemon.state.json file. +func printStateInfo() { + state := agent.ReadState() + if state == nil { + color.New(color.FgHiBlack).Println(" State: no state file (daemon not running or crashed)") + fmt.Println() + return + } + dim := color.New(color.FgHiBlack) + fmt.Println() + dim.Println(" Local state:") + fmt.Printf(" PID: %d\n", state.PID) + fmt.Printf(" Status: %s\n", state.Status) + fmt.Printf(" Version: %s\n", state.Version) + fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt))) + fmt.Printf(" Heartbeat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat))) + fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks) + fmt.Println() +} + +// svcExec runs a service management command with output flowing to the terminal. +func svcExec(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// svcExecInteractive is like svcExec but also connects stdin (needed for follow/pager modes). +func svcExecInteractive(name string, args ...string) error { + cmd := exec.Command(name, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/cmd/daemon_install.go b/internal/cmd/daemon_install.go index 8f1c0b6..e67e272 100644 --- a/internal/cmd/daemon_install.go +++ b/internal/cmd/daemon_install.go @@ -6,10 +6,14 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" + "strings" "text/template" "github.com/fatih/color" "github.com/spf13/cobra" + "github.com/torrentclaw/unarr/internal/agent" + "github.com/torrentclaw/unarr/internal/config" ) const systemdTemplate = `[Unit] @@ -123,6 +127,8 @@ func runDaemonInstall() error { return installSystemd(data, green) case "darwin": return installLaunchd(data, green) + case "windows": + return installWindowsTask(data, green) default: return fmt.Errorf("service installation not supported on %s yet", runtime.GOOS) } @@ -228,6 +234,17 @@ func runDaemonUninstall() error { os.Remove(path) green.Printf(" ✓ Removed %s\n", path) + case "windows": + // Stop the running process if any + if state := agent.ReadState(); state != nil { + exec.Command("taskkill", "/pid", strconv.Itoa(state.PID), "/f").Run() + } + out, err := exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").CombinedOutput() + if err != nil && !strings.Contains(string(out), "cannot find") { + return fmt.Errorf("remove scheduled task: %w\n%s", err, strings.TrimSpace(string(out))) + } + green.Println(" ✓ Scheduled task removed") + default: return fmt.Errorf("service uninstall not supported on %s yet", runtime.GOOS) } @@ -235,3 +252,45 @@ func runDaemonUninstall() error { fmt.Println() return nil } + +func installWindowsTask(data serviceData, green *color.Color) error { + logDir := config.DataDir() + os.MkdirAll(logDir, 0o755) + + // Remove any existing task before (re)installing. + exec.Command("schtasks", "/delete", "/tn", "unarr", "/f").Run() + + // Wrap with PowerShell so stdout/stderr are captured to a log file. + psScript := fmt.Sprintf( + `Start-Transcript -Path '%s\unarr.log' -Append -NoClobber; & '%s' start`, + logDir, data.BinPath, + ) + taskCmd := fmt.Sprintf(`powershell.exe -NonInteractive -WindowStyle Hidden -Command "%s"`, psScript) + + out, err := exec.Command("schtasks", + "/create", + "/tn", "unarr", + "/tr", taskCmd, + "/sc", "onlogon", + "/ru", data.User, + "/rl", "highest", + "/f", + ).CombinedOutput() + if err != nil { + return fmt.Errorf("create scheduled task: %w\n%s", err, strings.TrimSpace(string(out))) + } + + fmt.Println() + green.Println(" ✓ Installed! Service will start automatically at next login.") + fmt.Println() + fmt.Println(" To start now:") + fmt.Println(" unarr daemon start") + fmt.Println() + fmt.Println(" Manage with:") + fmt.Println(" unarr daemon status") + fmt.Println(" unarr daemon stop") + fmt.Printf(" unarr daemon logs (log: %s\\unarr.log)\n", logDir) + fmt.Println() + + return nil +} diff --git a/internal/cmd/reload_unix.go b/internal/cmd/reload_unix.go index 8aa9177..056112f 100644 --- a/internal/cmd/reload_unix.go +++ b/internal/cmd/reload_unix.go @@ -3,11 +3,13 @@ package cmd import ( + "fmt" "log" "os" "os/signal" "syscall" + "github.com/fatih/color" "github.com/torrentclaw/unarr/internal/agent" "github.com/torrentclaw/unarr/internal/config" ) @@ -38,3 +40,37 @@ func startReloadWatcher(rc *ReloadableConfig) { } }() } + +// sendReloadSignal sends SIGUSR1 to the running daemon process. +func sendReloadSignal() error { + state := agent.ReadState() + if state == nil { + return fmt.Errorf("daemon does not appear to be running (state file not found)") + } + p, err := os.FindProcess(state.PID) + if err != nil { + return fmt.Errorf("find process %d: %w", state.PID, err) + } + if err := p.Signal(syscall.SIGUSR1); err != nil { + return fmt.Errorf("send reload signal to PID %d: %w", state.PID, err) + } + fmt.Println() + color.New(color.FgGreen).Printf(" ✓ Reload signal sent to daemon (PID %d)\n", state.PID) + fmt.Println(" Config will be re-read shortly.") + fmt.Println() + return nil +} + +// killPID sends SIGTERM to the given PID for a graceful shutdown. +func killPID(pid int) error { + p, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find process %d: %w", pid, err) + } + if err := p.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("stop daemon (PID %d): %w", pid, err) + } + color.New(color.FgGreen).Printf(" ✓ Stop signal sent to daemon (PID %d)\n", pid) + fmt.Println() + return nil +} diff --git a/internal/cmd/reload_windows.go b/internal/cmd/reload_windows.go index d9e042e..b70ec66 100644 --- a/internal/cmd/reload_windows.go +++ b/internal/cmd/reload_windows.go @@ -2,7 +2,15 @@ package cmd -import "github.com/torrentclaw/unarr/internal/agent" +import ( + "fmt" + "os" + "os/exec" + "strconv" + + "github.com/fatih/color" + "github.com/torrentclaw/unarr/internal/agent" +) // ReloadableConfig holds a reference to the daemon for hot-reload. type ReloadableConfig struct { @@ -11,3 +19,25 @@ type ReloadableConfig struct { // startReloadWatcher is a no-op on Windows (no SIGUSR1 support). func startReloadWatcher(_ *ReloadableConfig) {} + +// sendReloadSignal is not supported on Windows; instructs the user to restart instead. +func sendReloadSignal() error { + fmt.Println() + color.New(color.FgYellow).Println(" ⚠ Config reload via signal is not supported on Windows.") + fmt.Println(" Use 'unarr daemon restart' to apply configuration changes.") + fmt.Println() + return nil +} + +// killPID stops the daemon process on Windows using taskkill. +func killPID(pid int) error { + cmd := exec.Command("taskkill", "/pid", strconv.Itoa(pid), "/f") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("stop daemon (PID %d): %w", pid, err) + } + color.New(color.FgGreen).Printf(" ✓ Daemon stopped (PID %d)\n", pid) + fmt.Println() + return nil +} From 6955b6144b9bb53684cbb50e19663f8618655f62 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Fri, 10 Apr 2026 19:18:38 +0200 Subject: [PATCH 09/89] chore(release): 0.7.0 - Bump version to 0.7.0 - 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 211ebf8..8e3d1e1 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.7.0] - 2026-04-10 + + +### Added + +- **daemon**: enhance service management with start, stop, restart, and status commands for Windows ## [0.6.8] - 2026-04-10 ### Added - **library**: add server-driven file deletion with allow_delete config + +### Other + +- **release**: 0.6.8 ## [0.6.7] - 2026-04-10 @@ -249,6 +259,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - remove UPX compression (antivirus false positives, startup penalty) - add -s -w -trimpath to Makefile, add build-small target with UPX +[0.7.0]: https://github.com/torrentclaw/unarr/compare/v0.6.8...v0.7.0 [0.6.8]: https://github.com/torrentclaw/unarr/compare/v0.6.7...v0.6.8 [0.6.7]: https://github.com/torrentclaw/unarr/compare/v0.6.6...v0.6.7 [0.6.6]: https://github.com/torrentclaw/unarr/compare/v0.6.5...v0.6.6 diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 68d857f..3b5a820 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.6.8" +var Version = "0.7.0" From f6117ddeb9e34bde9e015791e875d8d7014edb8e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 08:59:58 +0200 Subject: [PATCH 10/89] =?UTF-8?q?feat(torrent):=20act=20as=20WebTorrent=20?= =?UTF-8?q?peer=20for=20browser=20=E2=86=94=20unarr=20P2P=20streaming?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires anacrolix/torrent's built-in webtorrent package so a browser running webtorrent.js can fetch pieces from this CLI via WebRTC data channels. The daemon stays the seeder; we never relay bytes through TorrentClaw infrastructure — same legal posture as today. Changes: - internal/config: new [downloads.webrtc] section (enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass). Disabled by default, opt-in via config.toml. When enabled but trackers / STUN slices are empty, defaults are reapplied on Load() so users get a working setup with a single `enabled = true`. - internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers / ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList and forces NoUpload=false when WebRTC is on (browsers can't pull otherwise). buildMagnet now accepts variadic extra trackers and the downloader method prepends WSS trackers so anacrolix's webtorrent.TrackerClient picks them up first. - internal/engine/webrtc.go: BuildICEServers helper converts the TOML WebRTCConfig into []webrtc.ICEServer with shared TURN credentials. - internal/cmd/daemon.go + download.go: pass WebRTC config through to the engine. Tests (8 new, all green; full suite 0 lint issues, 0 vet): - buildMagnet free function: defaults-only, with extras, trim+empty-skip - downloader method: WebRTC disabled keeps WSS out, enabled prepends them - BuildICEServers: nil when disabled, STUN-only path, TURN+credentials - NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC peer enabled, magnet contains wss://tracker.torrentclaw.com) End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a manual test once tracker.torrentclaw.com WSS is live. --- internal/cmd/daemon.go | 3 + internal/cmd/download.go | 3 + internal/config/config.go | 52 ++++++++-- internal/engine/torrent.go | 52 +++++++++- internal/engine/webrtc.go | 36 +++++++ internal/engine/webrtc_test.go | 177 +++++++++++++++++++++++++++++++++ 6 files changed, 310 insertions(+), 13 deletions(-) create mode 100644 internal/engine/webrtc.go create mode 100644 internal/engine/webrtc_test.go diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index b8db356..46059fd 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -189,6 +189,9 @@ func runDaemonStart() error { MaxUploadRate: maxUl, ListenPort: cfg.Download.ListenPort, SeedEnabled: false, + WebRTCEnabled: cfg.Download.WebRTC.Enabled, + WebRTCTrackers: cfg.Download.WebRTC.Trackers, + ICEServers: engine.BuildICEServers(cfg.Download.WebRTC), }) 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..5189166 100644 --- a/internal/cmd/download.go +++ b/internal/cmd/download.go @@ -114,6 +114,9 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error { StallTimeout: 10 * time.Minute, MaxTimeout: 0, // unlimited SeedEnabled: false, + WebRTCEnabled: cfg.Download.WebRTC.Enabled, + WebRTCTrackers: cfg.Download.WebRTC.Trackers, + ICEServers: engine.BuildICEServers(cfg.Download.WebRTC), }) if err != nil { return fmt.Errorf("create downloader: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 5c593d5..cb53280 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -34,16 +34,30 @@ type AgentConfig struct { } type DownloadConfig struct { - Dir string `toml:"dir"` - PreferredMethod string `toml:"preferred_method"` - PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection - MaxConcurrent int `toml:"max_concurrent"` - MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited - MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited - MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") - StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") - ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) - StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) + Dir string `toml:"dir"` + PreferredMethod string `toml:"preferred_method"` + PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection + MaxConcurrent int `toml:"max_concurrent"` + MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited + MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited + MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0") + StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m") + ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random) + StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818) + WebRTC WebRTCConfig `toml:"webrtc"` +} + +// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers +// can fetch pieces via WebRTC data channels — required by the in-browser +// player on torrentclaw.com. Disabled by default; enabling implies upload +// is allowed for active torrents (browsers can't download otherwise). +type WebRTCConfig struct { + Enabled bool `toml:"enabled"` // master switch + Trackers []string `toml:"trackers"` // wss:// signaling trackers + STUNServers []string `toml:"stun_servers"` // stun:host:port + TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed + TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers + TURNPass string `toml:"turn_pass"` // optional } type OrganizeConfig struct { @@ -86,6 +100,11 @@ func Default() Config { PreferredMethod: "auto", MaxConcurrent: 3, StreamPort: 11818, + WebRTC: WebRTCConfig{ + Enabled: false, + Trackers: []string{"wss://tracker.torrentclaw.com"}, + STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"}, + }, }, Organize: OrganizeConfig{ Enabled: true, @@ -144,6 +163,19 @@ func Load(path string) (Config, error) { if cfg.Download.StreamPort == 0 { cfg.Download.StreamPort = 11818 } + // Re-apply WebRTC defaults only when the user enabled WebRTC but didn't + // supply trackers/STUN — leave both empty if disabled to keep config diffs clean. + if cfg.Download.WebRTC.Enabled { + if len(cfg.Download.WebRTC.Trackers) == 0 { + cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"} + } + if len(cfg.Download.WebRTC.STUNServers) == 0 { + cfg.Download.WebRTC.STUNServers = []string{ + "stun:stun.l.google.com:19302", + "stun:stun1.l.google.com:19302", + } + } + } return cfg, nil } diff --git a/internal/engine/torrent.go b/internal/engine/torrent.go index 9a916df..5b1d16d 100644 --- a/internal/engine/torrent.go +++ b/internal/engine/torrent.go @@ -16,6 +16,7 @@ import ( alog "github.com/anacrolix/log" "github.com/anacrolix/torrent" "github.com/anacrolix/torrent/storage" + "github.com/pion/webrtc/v4" "github.com/torrentclaw/unarr/internal/config" "golang.org/x/term" "golang.org/x/time/rate" @@ -70,6 +71,14 @@ type TorrentConfig struct { SeedEnabled bool SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime) SeedTime time.Duration // min seed time after completion (default 0) + + // WebRTC peer (WebTorrent protocol) for browser ↔ unarr P2P streaming. + // When enabled, anacrolix/torrent's built-in webtorrent package handles + // the WSS signaling + WebRTC data channels. Implies upload allowed for + // every torrent in the client (browsers can't pull pieces otherwise). + WebRTCEnabled bool + WebRTCTrackers []string // wss://… signaling trackers added to every magnet + ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal } // TorrentDownloader downloads torrents via BitTorrent P2P. @@ -96,9 +105,27 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) { tcfg := torrent.NewDefaultClientConfig() tcfg.DataDir = cfg.DataDir tcfg.Seed = cfg.SeedEnabled - tcfg.NoUpload = !cfg.SeedEnabled + // WebRTC peers (browsers) can only pull pieces from us if upload is + // enabled. We honour SeedEnabled for the long-tail seed-after-complete + // behaviour but unconditionally allow upload while WebRTC is on so an + // active download can still serve to a watching browser. + tcfg.NoUpload = !cfg.SeedEnabled && !cfg.WebRTCEnabled tcfg.Logger = alog.Default.FilterLevel(alog.Critical) + // WebRTC / WebTorrent peer: anacrolix auto-routes ws://+wss:// trackers + // to the bundled webtorrent.TrackerClient. We only need to populate the + // ICE server list so the SDP offers we send carry usable candidates. + if cfg.WebRTCEnabled { + tcfg.DisableWebtorrent = false + if len(cfg.ICEServers) > 0 { + tcfg.ICEServerList = cfg.ICEServers + } + log.Printf("[torrent] WebRTC peer enabled (trackers=%d ice_servers=%d)", + len(cfg.WebRTCTrackers), len(cfg.ICEServers)) + } else { + tcfg.DisableWebtorrent = true + } + // --- Performance optimizations --- // Storage: mmap instead of default file backend. @@ -235,7 +262,7 @@ func (d *TorrentDownloader) Available(_ context.Context, task *Task) (bool, erro } func (d *TorrentDownloader) Download(ctx context.Context, task *Task, outputDir string, progressCh chan<- Progress) (*Result, error) { - magnet := buildMagnet(task.InfoHash) + magnet := d.buildMagnet(task.InfoHash) t, err := d.client.AddMagnet(magnet) if err != nil { @@ -604,14 +631,33 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota return totalBytes, fileName } -func buildMagnet(infoHash string) string { +// buildMagnet composes a magnet URI for the info hash. extraTrackers (e.g. +// wss://… for WebRTC peer signaling) are prepended so anacrolix's +// webtorrent.TrackerClient picks them up first; the static UDP list +// follows. Empty / whitespace entries in extraTrackers are skipped. +func buildMagnet(infoHash string, extraTrackers ...string) string { params := []string{"xt=urn:btih:" + infoHash} + for _, t := range extraTrackers { + t = strings.TrimSpace(t) + if t == "" { + continue + } + params = append(params, "tr="+url.QueryEscape(t)) + } for _, tracker := range defaultTrackers { params = append(params, "tr="+url.QueryEscape(tracker)) } return "magnet:?" + strings.Join(params, "&") } +// buildMagnet on the downloader injects its WebRTC trackers when enabled. +func (d *TorrentDownloader) buildMagnet(infoHash string) string { + if d != nil && d.cfg.WebRTCEnabled { + return buildMagnet(infoHash, d.cfg.WebRTCTrackers...) + } + return buildMagnet(infoHash) +} + func formatBytes(b int64) string { const unit = 1024 if b < unit { diff --git a/internal/engine/webrtc.go b/internal/engine/webrtc.go new file mode 100644 index 0000000..28a81a4 --- /dev/null +++ b/internal/engine/webrtc.go @@ -0,0 +1,36 @@ +package engine + +import ( + "github.com/pion/webrtc/v4" + "github.com/torrentclaw/unarr/internal/config" +) + +// BuildICEServers converts a config.WebRTCConfig into the +// []webrtc.ICEServer slice that anacrolix/torrent's webtorrent client +// needs. STUN entries become bare URLs; TURN entries inherit the shared +// TURNUser / TURNPass credentials. Returns nil when WebRTC is disabled. +func BuildICEServers(cfg config.WebRTCConfig) []webrtc.ICEServer { + if !cfg.Enabled { + return nil + } + var servers []webrtc.ICEServer + for _, s := range cfg.STUNServers { + if s == "" { + continue + } + servers = append(servers, webrtc.ICEServer{URLs: []string{s}}) + } + for _, t := range cfg.TURNServers { + if t == "" { + continue + } + entry := webrtc.ICEServer{URLs: []string{t}} + if cfg.TURNUser != "" { + entry.Username = cfg.TURNUser + entry.Credential = cfg.TURNPass + entry.CredentialType = webrtc.ICECredentialTypePassword + } + servers = append(servers, entry) + } + return servers +} diff --git a/internal/engine/webrtc_test.go b/internal/engine/webrtc_test.go new file mode 100644 index 0000000..efae41d --- /dev/null +++ b/internal/engine/webrtc_test.go @@ -0,0 +1,177 @@ +package engine + +import ( + "context" + "net/url" + "strings" + "testing" + + "github.com/pion/webrtc/v4" + "github.com/torrentclaw/unarr/internal/config" +) + +const validHash = "aaf2c71b0e0a03d3f9b2a3e1d5c6b7a8f0e1d2c3" + +// TestBuildMagnet_NoExtras verifies the legacy free-function path keeps +// emitting only the static defaultTrackers list. +func TestBuildMagnet_NoExtras(t *testing.T) { + got := buildMagnet(validHash) + if !strings.HasPrefix(got, "magnet:?xt=urn:btih:"+validHash) { + t.Fatalf("magnet missing xt: %s", got) + } + if !strings.Contains(got, url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")) { + t.Fatal("expected default UDP tracker absent") + } + if strings.Contains(got, "wss%3A") { + t.Fatalf("unexpected WSS tracker leaked when none requested: %s", got) + } +} + +// TestBuildMagnet_WithExtraTrackers verifies extraTrackers (e.g. WebRTC +// WSS endpoints) are prepended before the defaults and properly URL-encoded. +func TestBuildMagnet_WithExtraTrackers(t *testing.T) { + got := buildMagnet(validHash, "wss://tracker.torrentclaw.com") + encWss := url.QueryEscape("wss://tracker.torrentclaw.com") + encUDP := url.QueryEscape("udp://tracker.opentrackr.org:1337/announce") + if !strings.Contains(got, "tr="+encWss) { + t.Fatalf("WSS tracker missing: %s", got) + } + wssIdx := strings.Index(got, encWss) + udpIdx := strings.Index(got, encUDP) + if wssIdx < 0 || udpIdx < 0 || wssIdx > udpIdx { + t.Fatalf("WSS tracker should appear BEFORE UDP defaults: wss=%d udp=%d", wssIdx, udpIdx) + } +} + +// TestBuildMagnet_TrimsAndSkipsEmpty makes sure callers passing config-derived +// slices with stray whitespace or empty strings don't get malformed magnets. +func TestBuildMagnet_TrimsAndSkipsEmpty(t *testing.T) { + got := buildMagnet(validHash, " wss://tracker.torrentclaw.com ", "", " ") + encWss := url.QueryEscape("wss://tracker.torrentclaw.com") + if !strings.Contains(got, "tr="+encWss) { + t.Fatalf("trimmed WSS tracker missing: %s", got) + } + if strings.Contains(got, "tr=&") || strings.HasSuffix(got, "tr=") { + t.Fatalf("empty tracker emitted: %s", got) + } +} + +// TestTorrentDownloader_buildMagnet_WebRTCDisabled confirms the downloader +// method does NOT inject WebRTCTrackers when WebRTCEnabled is false. +func TestTorrentDownloader_buildMagnet_WebRTCDisabled(t *testing.T) { + d := &TorrentDownloader{cfg: TorrentConfig{ + WebRTCEnabled: false, + WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"}, + }} + got := d.buildMagnet(validHash) + if strings.Contains(got, "wss%3A") { + t.Fatalf("WSS tracker leaked while WebRTCEnabled=false: %s", got) + } +} + +// TestTorrentDownloader_buildMagnet_WebRTCEnabled confirms the WSS trackers +// are present when WebRTCEnabled is true. +func TestTorrentDownloader_buildMagnet_WebRTCEnabled(t *testing.T) { + d := &TorrentDownloader{cfg: TorrentConfig{ + WebRTCEnabled: true, + WebRTCTrackers: []string{"wss://tracker.torrentclaw.com", "wss://tracker2.example.com"}, + }} + got := d.buildMagnet(validHash) + for _, want := range []string{ + "wss://tracker.torrentclaw.com", + "wss://tracker2.example.com", + } { + if !strings.Contains(got, url.QueryEscape(want)) { + t.Fatalf("expected tracker %q missing in magnet: %s", want, got) + } + } +} + +// TestBuildICEServers_DisabledReturnsNil ensures we don't leak STUN/TURN +// configuration into the torrent client when the user has WebRTC off. +func TestBuildICEServers_DisabledReturnsNil(t *testing.T) { + got := BuildICEServers(config.WebRTCConfig{ + Enabled: false, + STUNServers: []string{"stun:stun.l.google.com:19302"}, + }) + if got != nil { + t.Fatalf("expected nil ICE servers when disabled, got %+v", got) + } +} + +// TestBuildICEServers_STUNOnly converts STUN entries to bare ICEServer +// records with no credentials. +func TestBuildICEServers_STUNOnly(t *testing.T) { + got := BuildICEServers(config.WebRTCConfig{ + Enabled: true, + STUNServers: []string{"stun:stun.l.google.com:19302", "", "stun:stun1.l.google.com:19302"}, + }) + if len(got) != 2 { + t.Fatalf("expected 2 STUN servers (empty skipped), got %d (%+v)", len(got), got) + } + if got[0].URLs[0] != "stun:stun.l.google.com:19302" { + t.Fatalf("first server unexpected: %+v", got[0]) + } + if got[0].Username != "" || got[0].Credential != nil { + t.Fatalf("STUN entry should have no credentials, got %+v", got[0]) + } +} + +// TestNewTorrentDownloader_WebRTCEnabled creates a downloader with the +// WebRTC peer fully wired up and confirms the constructor doesn't error +// (anacrolix accepts the ICE server list, port binds, etc.). +func TestNewTorrentDownloader_WebRTCEnabled(t *testing.T) { + dir := t.TempDir() + dl, err := NewTorrentDownloader(TorrentConfig{ + DataDir: dir, + ListenPort: 0, // let the OS pick — avoid clashes in CI + WebRTCEnabled: true, + WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"}, + ICEServers: BuildICEServers(config.WebRTCConfig{ + Enabled: true, + STUNServers: []string{"stun:stun.l.google.com:19302"}, + }), + }) + if err != nil { + t.Fatalf("WebRTC-enabled downloader failed to start: %v", err) + } + defer func() { + if err := dl.Shutdown(context.Background()); err != nil { + t.Logf("shutdown: %v", err) + } + }() + + // Magnet for any task should now contain the WSS tracker. + got := dl.buildMagnet(validHash) + if !strings.Contains(got, "wss%3A%2F%2Ftracker.torrentclaw.com") { + t.Fatalf("WebRTC magnet missing WSS tracker: %s", got) + } +} + +// TestBuildICEServers_TURNWithCreds applies TURNUser/TURNPass to every TURN +// entry so the operator only specifies them once. +func TestBuildICEServers_TURNWithCreds(t *testing.T) { + got := BuildICEServers(config.WebRTCConfig{ + Enabled: true, + STUNServers: []string{"stun:stun.l.google.com:19302"}, + TURNServers: []string{"turn:turn.example.com:3478"}, + TURNUser: "alice", + TURNPass: "s3cr3t", + }) + if len(got) != 2 { + t.Fatalf("expected 1 STUN + 1 TURN, got %d", len(got)) + } + turn := got[1] + if turn.URLs[0] != "turn:turn.example.com:3478" { + t.Fatalf("TURN URL wrong: %+v", turn) + } + if turn.Username != "alice" { + t.Fatalf("TURN username wrong: %s", turn.Username) + } + if turn.Credential != "s3cr3t" { + t.Fatalf("TURN credential wrong: %v", turn.Credential) + } + if turn.CredentialType != webrtc.ICECredentialTypePassword { + t.Fatalf("TURN credential type wrong: %v", turn.CredentialType) + } +} From aa291320f5638ab411cc5580524caf5f8531cf14 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 09:40:37 +0200 Subject: [PATCH 11/89] test(wstracker-probe): standalone Go binary to verify WSS tracker reachability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tiny `go run ./cmd/wstracker-probe` that spins up an anacrolix/torrent Client with WebRTC enabled, advertises a random info_hash to the given WSS tracker, and reports via Callbacks.StatusUpdated whether the announce round-trip succeeded. Used as the production smoke for unarr ↔ wss://tracker.torrentclaw.com: $ /tmp/wstracker-probe -tracker wss://tracker.torrentclaw.com -timeout 30s [probe] tracker=wss://tracker.torrentclaw.com info_hash=e978df8d... timeout=30s [probe] tracker connected: wss://tracker.torrentclaw.com [probe] tracker announce OK: wss://tracker.torrentclaw.com ih=e978df8d... [probe] OK — tracker announce succeeded Disables TCP/uTP/DHT/IPv6/UPnP — only the WS tracker path matters here. Exit codes: 0 success, 1 announce error, 2 timeout. --- cmd/wstracker-probe/main.go | 117 ++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 cmd/wstracker-probe/main.go diff --git a/cmd/wstracker-probe/main.go b/cmd/wstracker-probe/main.go new file mode 100644 index 0000000..660e297 --- /dev/null +++ b/cmd/wstracker-probe/main.go @@ -0,0 +1,117 @@ +// wstracker-probe — connects to a WebSocket BitTorrent tracker, advertises +// a fake info_hash, and reports whether the announce succeeds. +// +// Usage: +// +// go run ./cmd/wstracker-probe -tracker wss://tracker.torrentclaw.com +// +// Exit code 0 on TrackerAnnounceSuccessful, 1 on timeout/error. +package main + +import ( + "context" + "crypto/rand" + "flag" + "fmt" + "log" + "os" + "time" + + alog "github.com/anacrolix/log" + "github.com/anacrolix/torrent" + "github.com/anacrolix/torrent/storage" + "github.com/pion/webrtc/v4" +) + +func main() { + tracker := flag.String("tracker", "wss://tracker.torrentclaw.com", "WSS tracker URL to probe") + timeout := flag.Duration("timeout", 30*time.Second, "max wait for successful announce") + flag.Parse() + + tmp, err := os.MkdirTemp("", "wstracker-probe-*") + if err != nil { + log.Fatalf("temp dir: %v", err) + } + defer os.RemoveAll(tmp) + + cfg := torrent.NewDefaultClientConfig() + cfg.DataDir = tmp + cfg.DefaultStorage = storage.NewMMap(tmp) + cfg.Seed = false + cfg.NoUpload = false + cfg.DisableTCP = true + cfg.DisableUTP = true + cfg.DisableIPv6 = true + cfg.NoDHT = true + cfg.NoDefaultPortForwarding = true + cfg.ListenPort = 0 + cfg.Logger = alog.Default.FilterLevel(alog.Critical) + cfg.DisableWebtorrent = false + cfg.ICEServerList = []webrtc.ICEServer{ + {URLs: []string{"stun:stun.l.google.com:19302"}}, + } + + annSuccess := make(chan struct{}, 1) + annError := make(chan error, 1) + cfg.Callbacks.StatusUpdated = append( + cfg.Callbacks.StatusUpdated, + func(e torrent.StatusUpdatedEvent) { + switch e.Event { //nolint:exhaustive // peer events are noise for tracker probe + case torrent.TrackerConnected: + if e.Error != nil { + fmt.Printf("[probe] tracker connect FAILED: %v\n", e.Error) + } else { + fmt.Printf("[probe] tracker connected: %s\n", e.Url) + } + case torrent.TrackerAnnounceSuccessful: + fmt.Printf("[probe] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash) + select { + case annSuccess <- struct{}{}: + default: + } + case torrent.TrackerAnnounceError: + fmt.Printf("[probe] tracker announce ERROR: %s ih=%s err=%v\n", e.Url, e.InfoHash, e.Error) + select { + case annError <- e.Error: + default: + } + case torrent.TrackerDisconnected: + fmt.Printf("[probe] tracker disconnected: %s err=%v\n", e.Url, e.Error) + } + }, + ) + + client, err := torrent.NewClient(cfg) + if err != nil { + log.Fatalf("create torrent client: %v", err) + } + defer client.Close() + + var ih [20]byte + if _, err := rand.Read(ih[:]); err != nil { + log.Fatalf("random info_hash: %v", err) + } + magnet := fmt.Sprintf("magnet:?xt=urn:btih:%x&tr=%s", ih, *tracker) + fmt.Printf("[probe] tracker=%s info_hash=%x timeout=%s\n", *tracker, ih, *timeout) + + t, err := client.AddMagnet(magnet) + if err != nil { + log.Fatalf("add magnet: %v", err) + } + defer t.Drop() + + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + + select { + case <-annSuccess: + fmt.Println("[probe] OK — tracker announce succeeded") + os.Exit(0) + case err := <-annError: + fmt.Printf("[probe] FAIL — tracker announce error: %v\n", err) + os.Exit(1) + case <-ctx.Done(): + fmt.Printf("[probe] FAIL — timeout after %s\n", *timeout) + os.Exit(2) + } +} From 727ab19468577624ba858b97bc295f89a3c7a791 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 09:49:32 +0200 Subject: [PATCH 12/89] feat(mediainfo): ResolveFFmpeg + DownloadFFmpeg mirroring ffprobe pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the ffmpeg-binary half of the resolution stack so the upcoming WebRTC streaming transcoder (Fase 3.3) has a single point of entry. Search order matches ResolveFFprobe so operators don't need to learn a second mental model: 1. Explicit path (--ffmpeg flag / library.ffmpeg_path config) 2. FFMPEG_PATH env var 3. "ffmpeg" on PATH (system install) 4. Adjacent to the unarr executable (release tarball bundles it here — this is the preferred path; see Fase 3.2 goreleaser changes) 5. Cache dir (sibling of the cached ffprobe binary) 6. Auto-download from ffbinaries.com (~70MB) as last resort Includes: - internal/library/mediainfo/ffmpeg.go — ResolveFFmpeg + actionable Docker / non-Docker error messages - internal/library/mediainfo/ffmpeg_download.go — DownloadFFmpeg, reuses ffprobePlatformKey + ffprobeAPIClient + ffprobeDLClient + extractFromZip helpers; bumps maxZipSize to 200MB (ffmpeg static is ~70-100MB) - internal/config: LibraryConfig.FFmpegPath toml field for explicit paths - 4 unit tests: explicit OK, explicit missing, env var, sibling cache path Tarball bundling and the actual transcoding pipeline land in the next two commits. --- internal/config/config.go | 1 + internal/library/mediainfo/ffmpeg.go | 79 ++++++++++++ internal/library/mediainfo/ffmpeg_download.go | 116 ++++++++++++++++++ internal/library/mediainfo/ffmpeg_test.go | 78 ++++++++++++ 4 files changed, 274 insertions(+) create mode 100644 internal/library/mediainfo/ffmpeg.go create mode 100644 internal/library/mediainfo/ffmpeg_download.go create mode 100644 internal/library/mediainfo/ffmpeg_test.go diff --git a/internal/config/config.go b/internal/config/config.go index cb53280..bb7498c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -84,6 +84,7 @@ type LibraryConfig struct { ScanPath string `toml:"scan_path"` // remembered from last scan Workers int `toml:"workers"` // concurrent ffprobe (default 8) FFprobePath string `toml:"ffprobe_path"` // optional explicit path + FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by WebRTC streaming transcoder) BackupDir string `toml:"backup_dir"` // for replaced files AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true) ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h") diff --git a/internal/library/mediainfo/ffmpeg.go b/internal/library/mediainfo/ffmpeg.go new file mode 100644 index 0000000..113e7c7 --- /dev/null +++ b/internal/library/mediainfo/ffmpeg.go @@ -0,0 +1,79 @@ +package mediainfo + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// ResolveFFmpeg finds the ffmpeg binary. Search order mirrors ResolveFFprobe +// so the same operator setup works for both: +// 1. Explicit path (--ffmpeg flag / library.ffmpeg_path config) +// 2. FFMPEG_PATH env var +// 3. "ffmpeg" on PATH +// 4. Adjacent to the current executable (release tarball bundles ffmpeg +// next to the unarr binary — this is the preferred install path) +// 5. Previously downloaded in the unarr cache dir +// 6. Auto-download static binary as last resort (~50MB, slow start) +// +// ffmpeg is required for the WebRTC streaming pipeline; ffprobe alone can't +// transcode HEVC/MKV to browser-friendly H.264/MP4 fragments. +func ResolveFFmpeg(explicit string) (string, error) { + if explicit != "" { + if _, err := os.Stat(explicit); err == nil { + return explicit, nil + } + return "", fmt.Errorf("ffmpeg not found at explicit path: %s", explicit) + } + + if envPath := os.Getenv("FFMPEG_PATH"); envPath != "" { + if _, err := os.Stat(envPath); err == nil { + return envPath, nil + } + } + + if p, err := exec.LookPath("ffmpeg"); err == nil { + return p, nil + } + + if exePath, err := os.Executable(); err == nil { + name := "ffmpeg" + if runtime.GOOS == "windows" { + name = "ffmpeg.exe" + } + adjacent := filepath.Join(filepath.Dir(exePath), name) + if _, err := os.Stat(adjacent); err == nil { + return adjacent, nil + } + } + + if cached, err := FFmpegCachePath(); err == nil { + if _, err := os.Stat(cached); err == nil { + return cached, nil + } + } + + if p, err := DownloadFFmpeg(); err == nil { + return p, nil + } + + if isDocker() { + return "", fmt.Errorf( + "ffmpeg not found and auto-download failed (read-only filesystem?).\n" + + "Options:\n" + + " • Use the official image: torrentclaw/unarr (includes ffmpeg)\n" + + " • Set FFMPEG_PATH env var to point to a pre-installed ffmpeg binary\n" + + " • Add to config.toml: [library]\\nffmpeg_path = \"/path/to/ffmpeg\"", + ) + } + return "", fmt.Errorf( + "ffmpeg not found and auto-download failed.\n" + + "Options:\n" + + " • Install ffmpeg: sudo apt install ffmpeg (or brew install ffmpeg)\n" + + " • Use the unarr release tarball — ffmpeg is bundled next to the binary\n" + + " • Set FFMPEG_PATH env var to point to the ffmpeg binary\n" + + " • Add to config.toml: [library]\\nffmpeg_path = \"/path/to/ffmpeg\"", + ) +} diff --git a/internal/library/mediainfo/ffmpeg_download.go b/internal/library/mediainfo/ffmpeg_download.go new file mode 100644 index 0000000..6d4f81c --- /dev/null +++ b/internal/library/mediainfo/ffmpeg_download.go @@ -0,0 +1,116 @@ +package mediainfo + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" +) + +const maxFFmpegZipSize = 200 * 1024 * 1024 // 200MB — ffmpeg static is ~70-100MB compressed + +// FFmpegCachePath returns the full path to the cached ffmpeg binary +// (sibling of the cached ffprobe binary). +func FFmpegCachePath() (string, error) { + dir, err := FFprobeCacheDir() + if err != nil { + return "", err + } + name := "ffmpeg" + if runtime.GOOS == "windows" { + name = "ffmpeg.exe" + } + return filepath.Join(dir, name), nil +} + +// DownloadFFmpeg downloads a static ffmpeg binary for the current platform +// and caches it locally. Returns the path to the binary. Reuses +// resolveFFprobeURL's ffbinaries.com discovery endpoint — that index ships +// both ffprobe and ffmpeg per platform. +func DownloadFFmpeg() (string, error) { + dest, err := FFmpegCachePath() + if err != nil { + return "", fmt.Errorf("cannot determine cache path: %w", err) + } + + if _, err := os.Stat(dest); err == nil { + return dest, nil + } + + platform, err := ffprobePlatformKey() + if err != nil { + return "", err + } + + url, err := resolveFFmpegURL(platform) + if err != nil { + return "", err + } + + fmt.Fprintf(os.Stderr, "ffmpeg not found — downloading for %s (~70MB)...\n", platform) + + resp, err := ffprobeDLClient.Get(url) + if err != nil { + return "", fmt.Errorf("download failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + } + + zipData, err := io.ReadAll(io.LimitReader(resp.Body, maxFFmpegZipSize)) + if err != nil { + return "", fmt.Errorf("download read failed: %w", err) + } + + name := "ffmpeg" + if runtime.GOOS == "windows" { + name = "ffmpeg.exe" + } + + binary, err := extractFromZip(zipData, name) + if err != nil { + return "", err + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return "", fmt.Errorf("cannot create cache directory: %w", err) + } + + if err := os.WriteFile(dest, binary, 0o755); err != nil { + return "", fmt.Errorf("cannot write ffmpeg binary: %w", err) + } + + fmt.Fprintf(os.Stderr, "ffmpeg installed to %s\n", dest) + return dest, nil +} + +// resolveFFmpegURL fetches the ffbinaries index and returns the ffmpeg +// download URL for the requested platform key (e.g. "linux-64"). +func resolveFFmpegURL(platform string) (string, error) { + resp, err := ffprobeAPIClient.Get(ffbinariesAPI) + if err != nil { + return "", fmt.Errorf("cannot reach ffbinaries.com: %w", err) + } + defer resp.Body.Close() + + var data ffbinariesResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", fmt.Errorf("cannot parse ffbinaries response: %w", err) + } + + bins, ok := data.Bin[platform] + if !ok { + return "", fmt.Errorf("no ffmpeg binary available for platform %q", platform) + } + + url, ok := bins["ffmpeg"] + if !ok { + return "", fmt.Errorf("no ffmpeg download URL for platform %q", platform) + } + + return url, nil +} diff --git a/internal/library/mediainfo/ffmpeg_test.go b/internal/library/mediainfo/ffmpeg_test.go new file mode 100644 index 0000000..f2dd9af --- /dev/null +++ b/internal/library/mediainfo/ffmpeg_test.go @@ -0,0 +1,78 @@ +package mediainfo + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +// TestResolveFFmpeg_ExplicitOK verifies the explicit-path branch returns +// the requested binary if it exists on disk. +func TestResolveFFmpeg_ExplicitOK(t *testing.T) { + dir := t.TempDir() + fake := filepath.Join(dir, "ffmpeg") + if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write fake: %v", err) + } + + got, err := ResolveFFmpeg(fake) + if err != nil { + t.Fatalf("ResolveFFmpeg(explicit): %v", err) + } + if got != fake { + t.Fatalf("got %q want %q", got, fake) + } +} + +// TestResolveFFmpeg_ExplicitMissing returns a clear error when the path +// the operator supplied doesn't exist — we do NOT silently fall back. +func TestResolveFFmpeg_ExplicitMissing(t *testing.T) { + _, err := ResolveFFmpeg("/nonexistent/path/ffmpeg-XXXXXX") + if err == nil { + t.Fatal("expected error for missing explicit path") + } +} + +// TestResolveFFmpeg_EnvVar honours FFMPEG_PATH when no explicit path is given. +func TestResolveFFmpeg_EnvVar(t *testing.T) { + dir := t.TempDir() + fake := filepath.Join(dir, "ffmpeg") + if err := os.WriteFile(fake, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write fake: %v", err) + } + t.Setenv("FFMPEG_PATH", fake) + // Hide the real ffmpeg from PATH so the env var is the next branch hit. + t.Setenv("PATH", "/nonexistent") + + got, err := ResolveFFmpeg("") + if err != nil { + t.Fatalf("ResolveFFmpeg(env): %v", err) + } + if got != fake { + t.Fatalf("got %q want %q (env-var branch)", got, fake) + } +} + +// TestFFmpegCachePath returns a sibling path to the ffprobe cache, +// consistent with the install layout the tarball produces. +func TestFFmpegCachePath(t *testing.T) { + got, err := FFmpegCachePath() + if err != nil { + t.Fatalf("FFmpegCachePath: %v", err) + } + want := "ffmpeg" + if runtime.GOOS == "windows" { + want = "ffmpeg.exe" + } + if filepath.Base(got) != want { + t.Fatalf("cache path basename = %q want %q", filepath.Base(got), want) + } + probeCache, err := FFprobeCachePath() + if err != nil { + t.Fatalf("FFprobeCachePath: %v", err) + } + if filepath.Dir(got) != filepath.Dir(probeCache) { + t.Fatalf("ffmpeg cache (%s) and ffprobe cache (%s) should share a directory", got, probeCache) + } +} From e68b127acc4a4b7bf8328e6beb7406f62b509faa Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 11:26:01 +0200 Subject: [PATCH 13/89] feat(release): bundle ffmpeg + ffprobe in tarballs and Docker image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operators no longer have to install ffmpeg manually. Both the release tarballs (5 platforms × 2 binaries) and the Docker image now ship a working ffmpeg + ffprobe pair adjacent to the unarr binary; ResolveFFmpeg / ResolveFFprobe pick them up via the "adjacent to executable" branch with zero configuration. Tarball bundle (scripts/download-ffmpeg-static.sh + .goreleaser.yml): - ffbinaries.com (johnvansickle / Zeranoe-style static GPL builds) for linux-amd64, linux-arm64, darwin-amd64, windows-amd64 - evermeet.cx universal Mach-O for darwin-arm64 (ffbinaries lacks it) - BtbN/FFmpeg-Builds for windows-arm64 (ffbinaries lacks it) - Idempotent fetch with curl --retry 5 so transient github.com SSL errors don't fail the goreleaser before-hook - New `before.hooks` runs the script automatically per release; archive files glob `dist-ffbinaries/{{ .Os }}-{{ .Arch }}/*` + strip_parent - Migrated to non-deprecated `formats: [tar.gz]` / `formats: [zip]` - Verified via `goreleaser release --snapshot --clean --skip=publish` — 6 archives all carry ffmpeg + ffprobe (~60-130MB each) Docker image (Dockerfile): - Replaced the failing BtbN static glibc binaries with Alpine's native musl `apk add ffmpeg`. The static GPL builds need glibc + libmvec / libgcc_s; gcompat alone is not enough (vector-math symbols unresolved). Alpine ships ffmpeg 6.1.2 which is fine for the WebRTC transcoder. - Image size 174MB, built + ffmpeg/ffprobe/unarr smoke OK. Targets the v0.8 unarr release (per user direction — new feature, not a patch). dist-ffbinaries/ added to .gitignore. --- .gitignore | 1 + .goreleaser.yml | 22 +++++- Dockerfile | 30 ++------ scripts/download-ffmpeg-static.sh | 117 ++++++++++++++++++++++++++++++ 4 files changed, 144 insertions(+), 26 deletions(-) create mode 100755 scripts/download-ffmpeg-static.sh diff --git a/.gitignore b/.gitignore index 0de3731..a6d17b3 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ Thumbs.db # GoReleaser dist/ +dist-ffbinaries/ # Docker tmp/ diff --git a/.goreleaser.yml b/.goreleaser.yml index 44656cd..0a5c821 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,6 +2,14 @@ version: 2 project_name: unarr +# Pre-build hook: fetch static ffmpeg + ffprobe per platform so each +# release tarball ships them adjacent to the unarr binary. ResolveFFmpeg / +# ResolveFFprobe pick them up via the "adjacent to executable" branch — no +# system install or runtime download needed. +before: + hooks: + - bash scripts/download-ffmpeg-static.sh + builds: - main: ./cmd/unarr/ binary: unarr @@ -20,11 +28,21 @@ builds: - -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }} archives: - - format: tar.gz + - formats: [tar.gz] name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" format_overrides: - goos: windows - format: zip + formats: [zip] + files: + - LICENSE* + - README* + # Bundle the matching ffmpeg + ffprobe (filename includes .exe on Windows + # because download-ffmpeg-static.sh writes ffmpeg.exe / ffprobe.exe there). + - src: "dist-ffbinaries/{{ .Os }}-{{ .Arch }}/*" + dst: . + strip_parent: true + info: + mode: 0o755 checksum: name_template: "checksums.txt" diff --git a/Dockerfile b/Dockerfile index f0e816f..1773622 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,3 @@ -# ---- ffprobe static binary stage ---- -# Download a static ffprobe build from BtbN/FFmpeg-Builds (GitHub CDN, reliable). -FROM alpine:3.22 AS ffprobe-dl - -RUN apk add --no-cache curl xz - -RUN ARCH=$(uname -m) && \ - case "$ARCH" in \ - x86_64) SLUG="linux64" ;; \ - aarch64) SLUG="linuxarm64" ;; \ - *) echo "Unsupported arch: $ARCH" && exit 1 ;; \ - esac && \ - curl -fsSL --retry 3 --retry-delay 5 \ - "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${SLUG}-gpl.tar.xz" \ - -o /tmp/ff.tar.xz && \ - mkdir /tmp/ffbuild && \ - tar xJ -f /tmp/ff.tar.xz --strip-components=1 -C /tmp/ffbuild/ && \ - mv /tmp/ffbuild/bin/ffprobe /usr/local/bin/ffprobe && \ - chmod +x /usr/local/bin/ffprobe && \ - rm -rf /tmp/ff.tar.xz /tmp/ffbuild && \ - ffprobe -version | head -1 - # ---- Build stage ---- FROM golang:1.25-alpine AS builder @@ -40,8 +18,13 @@ RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/inter # ---- Runtime stage ---- FROM alpine:3.22 +# 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 WebRTC +# transcoding pipeline (libx264 + libfdk-aac alternatives included). RUN apk upgrade --no-cache && \ - apk add --no-cache ca-certificates tzdata + apk add --no-cache ca-certificates tzdata ffmpeg # 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 @@ -53,7 +36,6 @@ RUN mkdir -p /config /downloads /data && \ USER unarr COPY --from=builder /unarr /usr/local/bin/unarr -COPY --from=ffprobe-dl /usr/local/bin/ffprobe /usr/local/bin/ffprobe # Environment: point config/data to container paths ENV UNARR_CONFIG_DIR=/config diff --git a/scripts/download-ffmpeg-static.sh b/scripts/download-ffmpeg-static.sh new file mode 100755 index 0000000..719fcde --- /dev/null +++ b/scripts/download-ffmpeg-static.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# scripts/download-ffmpeg-static.sh — fetch static ffmpeg + ffprobe binaries +# for every platform we ship. Run by goreleaser's `before.hooks` so each +# tarball can bundle the binaries adjacent to `unarr`. +# +# Source: https://ffbinaries.com (same index the runtime fallback uses). +# Output: +# dist-ffbinaries/-/{ffmpeg, ffprobe}[.exe] +# Idempotent: skips downloads when the target file already exists. + +set -euo pipefail + +# Map ffbinaries platform key → goreleaser {Os}-{Arch}. ffbinaries.com only +# ships an x86_64 macOS build; for darwin-arm64 we fall back to evermeet.cx +# universal binaries (handled separately below). +PLATFORMS=( + "linux-64:linux-amd64" + "linux-arm64:linux-arm64" + "osx-64:darwin-amd64" + "windows-64:windows-amd64" +) +DEST_ROOT="${FFBINARIES_DEST:-dist-ffbinaries}" +INDEX_URL="https://ffbinaries.com/api/v1/version/latest" + +for cmd in curl jq unzip; do + command -v "$cmd" >/dev/null 2>&1 || { + echo "[ffbin] missing required tool: $cmd" >&2 + exit 2 + } +done + +mkdir -p "$DEST_ROOT" + +echo "[ffbin] fetching index from $INDEX_URL" +INDEX_JSON="$(curl -fsSL "$INDEX_URL")" +VERSION="$(echo "$INDEX_JSON" | jq -r .version)" +echo "[ffbin] ffbinaries version: $VERSION" + +for entry in "${PLATFORMS[@]}"; do + ffbkey="${entry%%:*}" + goplat="${entry##*:}" + outdir="$DEST_ROOT/$goplat" + mkdir -p "$outdir" + + for tool in ffmpeg ffprobe; do + binname="$tool" + [[ "$goplat" == windows-* ]] && binname="${tool}.exe" + + if [ -f "$outdir/$binname" ]; then + echo "[ffbin] skip $goplat/$binname (already present)" + continue + fi + + url="$(echo "$INDEX_JSON" | jq -r ".bin[\"$ffbkey\"][\"$tool\"] // empty")" + if [ -z "$url" ]; then + echo "[ffbin] WARN $goplat/$tool: no download URL in index" >&2 + continue + fi + + tmpzip="$(mktemp --suffix=.zip)" + echo "[ffbin] fetch $goplat/$tool from $url" + curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors "$url" -o "$tmpzip" + unzip -p "$tmpzip" "$binname" > "$outdir/$binname" + chmod +x "$outdir/$binname" + rm -f "$tmpzip" + done +done + +# --- darwin-arm64 via evermeet.cx (universal binary; ffbinaries lacks it) --- +darwin_arm_dir="$DEST_ROOT/darwin-arm64" +mkdir -p "$darwin_arm_dir" +for tool in ffmpeg ffprobe; do + out="$darwin_arm_dir/$tool" + if [ -f "$out" ]; then + echo "[ffbin] skip darwin-arm64/$tool (already present)" + continue + fi + url="https://evermeet.cx/ffmpeg/getrelease/$tool/zip" + tmpzip="$(mktemp --suffix=.zip)" + echo "[ffbin] fetch darwin-arm64/$tool from $url" + curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors "$url" -o "$tmpzip" + unzip -p "$tmpzip" "$tool" > "$out" + chmod +x "$out" + rm -f "$tmpzip" +done + +# --- windows-arm64 via BtbN/FFmpeg-Builds (ffbinaries lacks it) --- +# BtbN ships a single zip per platform with ffmpeg.exe + ffprobe.exe under +# ffmpeg-master-latest-winarm64-gpl/bin/. Extract both in one fetch. +win_arm_dir="$DEST_ROOT/windows-arm64" +mkdir -p "$win_arm_dir" +needs_win_arm=0 +for tool in ffmpeg.exe ffprobe.exe; do + [ -f "$win_arm_dir/$tool" ] || needs_win_arm=1 +done +if [ "$needs_win_arm" = "1" ]; then + url="https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl.zip" + tmpzip="$(mktemp --suffix=.zip)" + echo "[ffbin] fetch windows-arm64/{ffmpeg,ffprobe}.exe from $url" + curl -fsSL --retry 5 --retry-delay 3 --retry-all-errors "$url" -o "$tmpzip" + for tool in ffmpeg.exe ffprobe.exe; do + out="$win_arm_dir/$tool" + member="$(unzip -Z1 "$tmpzip" "*/bin/$tool" 2>/dev/null | head -1)" + if [ -z "$member" ]; then + echo "[ffbin] WARN windows-arm64/$tool: not found in BtbN zip" >&2 + continue + fi + unzip -p "$tmpzip" "$member" > "$out" + chmod +x "$out" + done + rm -f "$tmpzip" +else + echo "[ffbin] skip windows-arm64 (already present)" +fi + +echo "[ffbin] done. layout:" +find "$DEST_ROOT" -type f -printf " %p (%s bytes)\n" From 75dcc0f1cb091e121db693d75ff0034be4f9d2b0 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 11:34:57 +0200 Subject: [PATCH 14/89] feat(streaming): ffmpeg transcoding pipeline (direct play / fMP4 / HW accel) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser-side WebRTC reproductor needs MP4 / H.264 / AAC / yuv420p to keep MSE happy. This package decides per request whether to: • direct-play — input already MSE-compatible, just remux to fMP4 • transcode — re-encode video (libx264 / NVENC / QSV / VAAPI / VideoToolbox) + audio (AAC), fragment to fMP4 Pieces: - internal/streaming/transcoder.go — AnalyzeCompatibility decides the recipe from a parsed mediainfo. CompatibilityReport carries the reasons so the player UI can show "transcoding video: HEVC → H.264". - internal/streaming/ffmpeg_args.go — BuildFFmpegArgs assembles the argv for ffmpeg. Direct play uses `-c copy`; transcode uses libx264 or the selected HW encoder. Output is always fragmented MP4 piped to stdout (-movflags frag_keyframe+empty_moov+default_base_moof) so the HTTP handler can stream straight to the browser without disk I/O. Quality ladder: 480p (1.5Mb), 720p (3.5Mb), 1080p (6Mb), 2160p (25Mb). Default 1080p when unset / unknown. -ss seek for resume / scrubbing. - internal/streaming/hwaccel.go — DetectHWAccel runs `ffmpeg -encoders` once per process and caches the best available. Order: NVENC → QSV → VAAPI → VideoToolbox → libx264. VAAPI is the only family that wires up HW decode too (`-hwaccel vaapi`); the others software-decode and HW- encode (works fine and avoids /dev/dri permission rabbit holes). - internal/streaming/stream.go — Transcoder facade wires Analyze + Stream together for the API handler in Fase 4. Captures the last 8 KiB of ffmpeg stderr for diagnosable errors without unbounded memory. Tests (20 unit, all green): - AnalyzeCompatibility: h264+aac direct, video-only direct, HEVC → transcode, 10-bit HDR → transcode, EAC3 audio → transcode, nil guards - ResolveQuality: empty + unknown fallback to 1080p, 4-step ladder - BuildFFmpegArgs: direct play -c copy, transcode libx264 + bitrate + scale, NVENC swaps encoder & drops preset, VAAPI injects -hwaccel + scale_vaapi, -ss timestamp formatting - HWAccel: encoder-name table, VAAPI is the only one with HW decode - formatDuration: zero, sub-second, HH:MM:SS, negative-clamped - cappedBuffer: tail retention through multi-write and large-write paths - NewTranscoder: rejects empty paths --- internal/streaming/ffmpeg_args.go | 173 +++++++++++++++++ internal/streaming/hwaccel.go | 144 ++++++++++++++ internal/streaming/stream.go | 131 +++++++++++++ internal/streaming/transcoder.go | 135 +++++++++++++ internal/streaming/transcoder_test.go | 267 ++++++++++++++++++++++++++ 5 files changed, 850 insertions(+) create mode 100644 internal/streaming/ffmpeg_args.go create mode 100644 internal/streaming/hwaccel.go create mode 100644 internal/streaming/stream.go create mode 100644 internal/streaming/transcoder.go create mode 100644 internal/streaming/transcoder_test.go diff --git a/internal/streaming/ffmpeg_args.go b/internal/streaming/ffmpeg_args.go new file mode 100644 index 0000000..1869864 --- /dev/null +++ b/internal/streaming/ffmpeg_args.go @@ -0,0 +1,173 @@ +package streaming + +import ( + "fmt" + "strconv" + "time" +) + +// StreamOptions controls a single transcode/remux invocation. +type StreamOptions struct { + // Quality caps the output resolution and bitrate when transcoding. + // Direct play ignores it (the source bitrate wins). One of: + // "2160p", "1080p", "720p", "480p", "" (= "1080p"). + Quality string + + // StartOffset seeks the input N seconds in before transcoding. Useful + // for resume / scrubbing. Zero means start from the beginning. + StartOffset time.Duration + + // HW selects the hardware encoder. "" (or "none") means software libx264. + HW HWAccel + + // AudioTrackIndex selects which audio track to keep (0-based, before + // the video stream is excluded). Zero is the default track. + AudioTrackIndex int +} + +// QualityProfile maps a Quality label to encoder constraints. +type QualityProfile struct { + Label string // "1080p" + MaxHeight int // 1080 + VideoBitrate int // bits/s for libx264 -b:v + AudioBitrate int // bits/s for AAC +} + +// qualityProfiles is the full ladder. We default to 1080p when unset. +var qualityProfiles = map[string]QualityProfile{ + "2160p": {Label: "2160p", MaxHeight: 2160, VideoBitrate: 25_000_000, AudioBitrate: 192_000}, + "1080p": {Label: "1080p", MaxHeight: 1080, VideoBitrate: 6_000_000, AudioBitrate: 160_000}, + "720p": {Label: "720p", MaxHeight: 720, VideoBitrate: 3_500_000, AudioBitrate: 128_000}, + "480p": {Label: "480p", MaxHeight: 480, VideoBitrate: 1_500_000, AudioBitrate: 96_000}, +} + +// ResolveQuality returns the QualityProfile for a label, falling back to +// 1080p when the label is empty / unknown. +func ResolveQuality(label string) QualityProfile { + if p, ok := qualityProfiles[label]; ok { + return p + } + return qualityProfiles["1080p"] +} + +// fragmentedMP4Movflags are the magic flags MSE needs to consume an +// ffmpeg pipe as it's produced — avoids the moov atom being written at the +// end of the file (which would force buffering the whole stream). +const fragmentedMP4Movflags = "frag_keyframe+empty_moov+default_base_moof" + +// BuildFFmpegArgs returns the argv (without the binary itself) for +// ffmpeg given the input file, stream options, and a compatibility report. +// +// Two recipes: +// +// - Direct play: -c copy on every selected stream + remux to fMP4. +// - Transcode: re-encode video (libx264 / hwaccel) + audio (aac). +// +// The result writes fMP4 fragments to stdout (`pipe:1`) so the HTTP +// handler can stream them directly to the browser without touching disk. +func BuildFFmpegArgs(inputPath string, report CompatibilityReport, opts StreamOptions) []string { + args := []string{ + "-hide_banner", + "-loglevel", "warning", + "-nostdin", + } + + if opts.HW.HasDecoder() { + args = append(args, opts.HW.DecoderArgs()...) + } + + if opts.StartOffset > 0 { + args = append(args, "-ss", formatDuration(opts.StartOffset)) + } + + args = append(args, "-i", inputPath) + + // Map first video + selected audio. Drop subtitles (browser handles + // them out-of-band; baking them in is a Phase 4.x decision). + args = append(args, + "-map", "0:v:0", + "-map", fmt.Sprintf("0:a:%d?", opts.AudioTrackIndex), + ) + + if report.DirectPlay { + // Cheap path: copy streams, just remux container. + args = append(args, "-c", "copy") + } else { + // Transcode path: pick encoder per HW. + profile := ResolveQuality(opts.Quality) + args = append(args, transcodeArgs(profile, opts.HW)...) + } + + args = append(args, + "-movflags", fragmentedMP4Movflags, + "-f", "mp4", + "pipe:1", + ) + return args +} + +// transcodeArgs returns the encoder + bitrate flags. Keeps the function +// flat so the BuildFFmpegArgs reader can scan the recipe top to bottom. +func transcodeArgs(profile QualityProfile, hw HWAccel) []string { + args := []string{} + + // Video encoder. + args = append(args, "-c:v", hw.VideoEncoder()) + + // Scale filter caps the long edge to MaxHeight, preserving aspect. + // `force_original_aspect_ratio=decrease` keeps it ≤ MaxHeight when + // the source is taller and leaves smaller sources untouched. The + // `force_divisible_by=2` keeps libx264 happy. + scale := fmt.Sprintf( + "scale=-2:%d:force_original_aspect_ratio=decrease:force_divisible_by=2", + profile.MaxHeight, + ) + if hw == HWAccelVAAPI { + // VAAPI needs frames in the GPU surface, scaling is done with + // scale_vaapi. We still upload via format=nv12. + scale = fmt.Sprintf("format=nv12,hwupload,scale_vaapi=-2:%d", profile.MaxHeight) + } + args = append(args, "-vf", scale) + + // Bitrate ceiling (variable bitrate with 2× burst). + args = append(args, + "-b:v", strconv.Itoa(profile.VideoBitrate), + "-maxrate", strconv.Itoa(profile.VideoBitrate*2), + "-bufsize", strconv.Itoa(profile.VideoBitrate*4), + ) + + // SW-only: tune for low latency + don't waste cycles on the deepest + // preset when we're feeding live playback. + if hw == HWAccelNone || hw == HWAccelUnset { + args = append(args, + "-preset", "veryfast", + "-tune", "zerolatency", + ) + } + + // Force yuv420p so MSE reliably plays the result (some libx264 + // configurations otherwise emit yuv422p for SD content). + args = append(args, "-pix_fmt", "yuv420p") + + // Audio: re-encode to AAC stereo. Mono / 5.1 sources are downmixed. + args = append(args, + "-c:a", "aac", + "-b:a", strconv.Itoa(profile.AudioBitrate), + "-ac", "2", + ) + + return args +} + +// formatDuration prints a Go Duration as ffmpeg's `-ss HH:MM:SS.mmm`. +func formatDuration(d time.Duration) string { + if d < 0 { + d = 0 + } + h := int(d / time.Hour) + d -= time.Duration(h) * time.Hour + m := int(d / time.Minute) + d -= time.Duration(m) * time.Minute + s := float64(d) / float64(time.Second) + return fmt.Sprintf("%02d:%02d:%06.3f", h, m, s) +} diff --git a/internal/streaming/hwaccel.go b/internal/streaming/hwaccel.go new file mode 100644 index 0000000..1c8dff6 --- /dev/null +++ b/internal/streaming/hwaccel.go @@ -0,0 +1,144 @@ +package streaming + +import ( + "context" + "os/exec" + "runtime" + "strings" + "sync" + "time" +) + +// HWAccel identifies which hardware encoder family the host can use. +type HWAccel string + +const ( + HWAccelUnset HWAccel = "" + HWAccelNone HWAccel = "none" // explicit software libx264 + HWAccelNVENC HWAccel = "nvenc" // NVIDIA GPUs + HWAccelQSV HWAccel = "qsv" // Intel Quick Sync (Linux/Win) + HWAccelVAAPI HWAccel = "vaapi" // Intel/AMD GPUs on Linux + HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS native +) + +// VideoEncoder returns the ffmpeg `-c:v` argument for this accelerator. +func (h HWAccel) VideoEncoder() string { + switch h { + case HWAccelNVENC: + return "h264_nvenc" + case HWAccelQSV: + return "h264_qsv" + case HWAccelVAAPI: + return "h264_vaapi" + case HWAccelVideoToolbox: + return "h264_videotoolbox" + default: + return "libx264" + } +} + +// HasDecoder reports whether the accelerator also supports HW decode. +// We always feed encoders software-decoded frames except for VAAPI where +// the GPU pipeline expects HW-decoded surfaces end-to-end. +func (h HWAccel) HasDecoder() bool { + return h == HWAccelVAAPI +} + +// DecoderArgs returns the ffmpeg flags that enable HW decode for this +// accelerator. Only meaningful when HasDecoder() == true. +func (h HWAccel) DecoderArgs() []string { + if h == HWAccelVAAPI { + return []string{ + "-hwaccel", "vaapi", + "-hwaccel_device", "/dev/dri/renderD128", + "-hwaccel_output_format", "vaapi", + } + } + return nil +} + +// detectedHWAccel caches the result of DetectHWAccel so we don't fork +// ffmpeg on every transcode request. +var ( + detectedHWAccelOnce sync.Once + detectedHWAccel HWAccel +) + +// DetectHWAccel asks ffmpeg what encoders it supports and returns the +// best available. Result is cached for the process lifetime — callers +// should construct the Transcoder once and reuse it. +// +// Detection order (best perf → fallback): +// 1. NVENC (NVIDIA GPU + CUDA driver) +// 2. QSV (Intel iGPU/dGPU + libmfx/intel-media-driver) +// 3. VAAPI (Linux Intel/AMD via /dev/dri) +// 4. VideoToolbox (macOS only) +// 5. None (fallback to libx264 software) +func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel { + detectedHWAccelOnce.Do(func() { + detectedHWAccel = doDetectHWAccel(ctx, ffmpegPath) + }) + return detectedHWAccel +} + +// ResetHWAccelCache forces the next DetectHWAccel call to re-probe. +// Intended for tests. +func ResetHWAccelCache() { + detectedHWAccelOnce = sync.Once{} + detectedHWAccel = HWAccelUnset +} + +func doDetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel { + if ctx == nil { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + } + + // macOS videotoolbox is reliable enough that we don't bother probing + // — every Apple Silicon Mac has it; Intel Macs since 10.13 do too. + if runtime.GOOS == "darwin" { + if encoderAvailable(ctx, ffmpegPath, "h264_videotoolbox") { + return HWAccelVideoToolbox + } + } + + for _, candidate := range []struct { + Name HWAccel + Encoder string + }{ + {HWAccelNVENC, "h264_nvenc"}, + {HWAccelQSV, "h264_qsv"}, + {HWAccelVAAPI, "h264_vaapi"}, + } { + if encoderAvailable(ctx, ffmpegPath, candidate.Encoder) { + return candidate.Name + } + } + + return HWAccelNone +} + +// encoderAvailable returns true when `ffmpeg -hide_banner -encoders` +// lists the named encoder. +// +// Note: this only verifies ffmpeg was COMPILED with the encoder. It does +// NOT guarantee the host hardware works at runtime — some users will see +// libx264 fall back at the first failed encode. That's OK; the worst +// case is a one-time slow request. +func encoderAvailable(ctx context.Context, ffmpegPath, encoder string) bool { + cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders") + out, err := cmd.Output() + if err != nil { + return false + } + for _, line := range strings.Split(string(out), "\n") { + // `-encoders` output looks like: + // V..... libx264 libx264 H.264 / AVC / MPEG-4 AVC + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == encoder { + return true + } + } + return false +} diff --git a/internal/streaming/stream.go b/internal/streaming/stream.go new file mode 100644 index 0000000..67d956e --- /dev/null +++ b/internal/streaming/stream.go @@ -0,0 +1,131 @@ +package streaming + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "sync" + + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +// Transcoder owns the resolved ffmpeg / ffprobe binaries plus the +// detected hardware accelerator. One per process; safe for concurrent use. +type Transcoder struct { + ffmpegPath string + ffprobePath string + + hwOnce sync.Once + hw HWAccel +} + +// NewTranscoder constructs a Transcoder from explicit binary paths. +// Both must be non-empty; resolve them upstream via +// mediainfo.ResolveFFmpeg / ResolveFFprobe. +func NewTranscoder(ffmpegPath, ffprobePath string) (*Transcoder, error) { + if ffmpegPath == "" { + return nil, errors.New("streaming: ffmpeg path is required") + } + if ffprobePath == "" { + return nil, errors.New("streaming: ffprobe path is required") + } + return &Transcoder{ + ffmpegPath: ffmpegPath, + ffprobePath: ffprobePath, + }, nil +} + +// HWAccel returns the cached / detected hardware accelerator. First call +// runs `ffmpeg -encoders`; subsequent calls reuse the result. +func (t *Transcoder) HWAccel(ctx context.Context) HWAccel { + t.hwOnce.Do(func() { + t.hw = DetectHWAccel(ctx, t.ffmpegPath) + }) + return t.hw +} + +// Analyze runs ffprobe on the input file and returns a compatibility +// report so the caller can decide direct play vs transcode. +func (t *Transcoder) Analyze(ctx context.Context, inputPath string) (CompatibilityReport, *mediainfo.MediaInfo, error) { + info, err := mediainfo.ExtractMediaInfo(ctx, t.ffprobePath, inputPath) + if err != nil { + return CompatibilityReport{}, nil, fmt.Errorf("streaming: ffprobe failed: %w", err) + } + return AnalyzeCompatibility(info), info, nil +} + +// Stream runs ffmpeg with the right recipe for the given file + options +// and writes fragmented MP4 to dst. Blocks until ffmpeg exits or the +// context is cancelled. If ffmpeg's stderr captures something useful, it's +// included in the returned error. +func (t *Transcoder) Stream(ctx context.Context, inputPath string, dst io.Writer, opts StreamOptions) error { + report, _, err := t.Analyze(ctx, inputPath) + if err != nil { + return err + } + return t.StreamWithReport(ctx, inputPath, dst, opts, report) +} + +// StreamWithReport is the lower-level entry point — accepts a +// pre-computed CompatibilityReport so the API handler can inspect the +// decision before kicking off a transcode (useful for billing / +// telemetry / quality-fallback policies). +func (t *Transcoder) StreamWithReport( + ctx context.Context, + inputPath string, + dst io.Writer, + opts StreamOptions, + report CompatibilityReport, +) error { + if opts.HW == HWAccelUnset { + opts.HW = t.HWAccel(ctx) + } + + args := BuildFFmpegArgs(inputPath, report, opts) + cmd := exec.CommandContext(ctx, t.ffmpegPath, args...) + cmd.Stdout = dst + + stderrBuf := newCappedBuffer(8 * 1024) // last 8 KiB is plenty for diagnosing + cmd.Stderr = stderrBuf + + if err := cmd.Run(); err != nil { + // Cancellation looks like an exec error too; surface the cause + // so callers don't blame ffmpeg for client disconnects. + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("streaming: ffmpeg exited: %w (stderr tail: %s)", err, stderrBuf.String()) + } + return nil +} + +// cappedBuffer is an io.Writer that keeps only the last `cap` bytes +// written. Used to capture ffmpeg's tail stderr for error reporting +// without unbounded memory growth on long transcodes. +type cappedBuffer struct { + buf []byte + cap int +} + +func newCappedBuffer(cap int) *cappedBuffer { + return &cappedBuffer{cap: cap} +} + +func (c *cappedBuffer) Write(p []byte) (int, error) { + if len(p) >= c.cap { + c.buf = append(c.buf[:0], p[len(p)-c.cap:]...) + return len(p), nil + } + if len(c.buf)+len(p) > c.cap { + drop := len(c.buf) + len(p) - c.cap + c.buf = c.buf[drop:] + } + c.buf = append(c.buf, p...) + return len(p), nil +} + +func (c *cappedBuffer) String() string { + return string(c.buf) +} diff --git a/internal/streaming/transcoder.go b/internal/streaming/transcoder.go new file mode 100644 index 0000000..8daa786 --- /dev/null +++ b/internal/streaming/transcoder.go @@ -0,0 +1,135 @@ +// Package streaming wraps ffmpeg for the WebRTC-streaming pipeline. +// +// The browser-side reproductor lives on torrentclaw.com and consumes +// fragmented MP4 (fMP4) chunks via Media Source Extensions (MSE). MSE is +// strict about codecs: H.264 / VP8 / VP9 / AV1 video + AAC / Opus / MP3 +// audio + MP4 / WebM container. Anything else (HEVC/x265, MKV, EAC3, FLAC, +// 10-bit H.264, …) needs transcoding. +// +// The transcoder picks one of two paths per request: +// +// - Direct play — input is already MSE-compatible. Container is remuxed +// to fragmented MP4 with the audio + video streams copied. Cheap: +// ~no CPU, ~no memory. +// +// - Transcode — input is incompatible. Re-encode video to H.264 +// (libx264 sw / h264_nvenc / h264_qsv / h264_vaapi / h264_videotoolbox +// depending on what the host supports) and audio to AAC. Expensive: +// 1× core for 1080p sw, ~free with HW accel. +package streaming + +import ( + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +// browserVideoCodecs lists video codecs the player can render natively +// without transcoding. Names match ffprobe's `codec_name`. +var browserVideoCodecs = map[string]struct{}{ + "h264": {}, + "vp8": {}, + "vp9": {}, + "av1": {}, +} + +// browserAudioCodecs lists audio codecs the player accepts natively. +var browserAudioCodecs = map[string]struct{}{ + "aac": {}, + "opus": {}, + "mp3": {}, +} + +// browserPixelFormats lists pixel formats MSE H.264 reliably decodes +// in-browser. 10-bit / 12-bit profiles are rejected because Safari + most +// Chromium versions software-decode them at 1-2 fps. +var browserPixelFormats = map[string]struct{}{ + "yuv420p": {}, + "yuvj420p": {}, +} + +// CompatibilityReport explains why a file is or isn't direct-playable. +// Returned by AnalyzeCompatibility so the caller can show actionable +// feedback (e.g. "transcoding video: HEVC → H.264"). +type CompatibilityReport struct { + DirectPlay bool + VideoCompat bool + AudioCompat bool + Container string // input container hint (best effort) + VideoCodec string + AudioCodec string + PixelFormat string + BitDepth int + Reasons []string // human-readable list of mismatches; empty when DirectPlay +} + +// AnalyzeCompatibility inspects a parsed mediainfo and decides whether the +// stream needs transcoding. It does NOT touch disk or run ffmpeg. +// +// Direct play requires ALL of: +// - Video codec ∈ {h264, vp8, vp9, av1} +// - Pixel format ∈ {yuv420p, yuvj420p} +// - Bit depth ≤ 8 +// - Audio codec ∈ {aac, opus, mp3} +// +// First audio track wins for the compatibility decision; later tracks are +// repacked along with it. Container is intentionally ignored — even MKV +// carrying H.264 + AAC can be remuxed to fMP4 cheaply, so it's not worth +// failing direct-play on container alone. +func AnalyzeCompatibility(info *mediainfo.MediaInfo) CompatibilityReport { + r := CompatibilityReport{} + if info == nil || info.Video == nil { + r.Reasons = append(r.Reasons, "missing video stream metadata") + return r + } + + r.VideoCodec = info.Video.Codec + r.PixelFormat = pixelFormatFor(info.Video) + r.BitDepth = info.Video.BitDepth + + _, vcOK := browserVideoCodecs[r.VideoCodec] + r.VideoCompat = vcOK + if !vcOK { + r.Reasons = append(r.Reasons, + "video codec "+r.VideoCodec+" not playable in browser") + } + if r.BitDepth > 8 { + r.VideoCompat = false + r.Reasons = append(r.Reasons, "video bit depth >8 (HDR / 10-bit)") + } + if r.PixelFormat != "" { + if _, ok := browserPixelFormats[r.PixelFormat]; !ok { + r.VideoCompat = false + r.Reasons = append(r.Reasons, + "pixel format "+r.PixelFormat+" not playable in browser") + } + } + + if len(info.Audio) > 0 { + r.AudioCodec = info.Audio[0].Codec + _, acOK := browserAudioCodecs[r.AudioCodec] + r.AudioCompat = acOK + if !acOK { + r.Reasons = append(r.Reasons, + "audio codec "+r.AudioCodec+" not playable in browser") + } + } else { + // No audio track — direct play allowed for video-only streams. + r.AudioCompat = true + } + + r.DirectPlay = r.VideoCompat && r.AudioCompat + return r +} + +// pixelFormatFor returns a best-effort pixel format string for a VideoInfo. +// mediainfo doesn't carry pix_fmt explicitly today, so we infer from the +// HDR flag: HDR streams are 10-bit yuv420p10le (incompatible by definition) +// while everything else is assumed yuv420p. +// +// Once mediainfo grows a PixFmt field we replace this heuristic with the +// raw value. +func pixelFormatFor(v *mediainfo.VideoInfo) string { + if v.HDR != "" || v.BitDepth >= 10 { + return "yuv420p10le" + } + return "yuv420p" +} diff --git a/internal/streaming/transcoder_test.go b/internal/streaming/transcoder_test.go new file mode 100644 index 0000000..42d4979 --- /dev/null +++ b/internal/streaming/transcoder_test.go @@ -0,0 +1,267 @@ +package streaming + +import ( + "strings" + "testing" + "time" + + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +// AnalyzeCompatibility — direct play happy paths. +func TestAnalyzeCompatibility_DirectPlayH264AAC(t *testing.T) { + info := &mediainfo.MediaInfo{ + Video: &mediainfo.VideoInfo{Codec: "h264", BitDepth: 8}, + Audio: []mediainfo.AudioTrack{{Codec: "aac", Channels: 2}}, + } + r := AnalyzeCompatibility(info) + if !r.DirectPlay { + t.Fatalf("h264+aac must be direct-playable, got %+v", r) + } + if len(r.Reasons) != 0 { + t.Fatalf("direct play should have no reasons, got %v", r.Reasons) + } +} + +func TestAnalyzeCompatibility_DirectPlayVideoOnly(t *testing.T) { + info := &mediainfo.MediaInfo{ + Video: &mediainfo.VideoInfo{Codec: "vp9", BitDepth: 8}, + } + r := AnalyzeCompatibility(info) + if !r.DirectPlay { + t.Fatalf("video-only vp9 must be direct-playable, got %+v", r) + } +} + +// AnalyzeCompatibility — transcode required. +func TestAnalyzeCompatibility_TranscodeHEVC(t *testing.T) { + info := &mediainfo.MediaInfo{ + Video: &mediainfo.VideoInfo{Codec: "hevc", BitDepth: 8}, + Audio: []mediainfo.AudioTrack{{Codec: "aac"}}, + } + r := AnalyzeCompatibility(info) + if r.DirectPlay { + t.Fatalf("HEVC must NOT be direct-playable") + } + if !strings.Contains(strings.Join(r.Reasons, ";"), "hevc") { + t.Fatalf("expected reason mentioning hevc, got %v", r.Reasons) + } +} + +func TestAnalyzeCompatibility_TranscodeHDR10bit(t *testing.T) { + info := &mediainfo.MediaInfo{ + Video: &mediainfo.VideoInfo{Codec: "h264", BitDepth: 10, HDR: "HDR10"}, + Audio: []mediainfo.AudioTrack{{Codec: "aac"}}, + } + r := AnalyzeCompatibility(info) + if r.DirectPlay { + t.Fatalf("10-bit HDR10 must NOT be direct-playable") + } +} + +func TestAnalyzeCompatibility_TranscodeEAC3Audio(t *testing.T) { + info := &mediainfo.MediaInfo{ + Video: &mediainfo.VideoInfo{Codec: "h264", BitDepth: 8}, + Audio: []mediainfo.AudioTrack{{Codec: "eac3", Channels: 6}}, + } + r := AnalyzeCompatibility(info) + if r.DirectPlay { + t.Fatalf("EAC3 audio must trigger transcode") + } + if r.VideoCompat != true { + t.Fatalf("video stayed h264 — VideoCompat should still be true; got %+v", r) + } +} + +func TestAnalyzeCompatibility_NilGuard(t *testing.T) { + r := AnalyzeCompatibility(nil) + if r.DirectPlay { + t.Fatal("nil MediaInfo must not be direct-playable") + } + r2 := AnalyzeCompatibility(&mediainfo.MediaInfo{Video: nil}) + if r2.DirectPlay { + t.Fatal("MediaInfo without video must not be direct-playable") + } +} + +// ResolveQuality — fallback + table lookup. +func TestResolveQuality_FallbackTo1080p(t *testing.T) { + got := ResolveQuality("") + if got.Label != "1080p" { + t.Fatalf("empty label fallback wrong: %s", got.Label) + } + got = ResolveQuality("garbage") + if got.Label != "1080p" { + t.Fatalf("unknown label fallback wrong: %s", got.Label) + } +} + +func TestResolveQuality_KnownLabels(t *testing.T) { + cases := map[string]int{ + "480p": 480, + "720p": 720, + "1080p": 1080, + "2160p": 2160, + } + for label, height := range cases { + got := ResolveQuality(label) + if got.MaxHeight != height { + t.Errorf("ResolveQuality(%q).MaxHeight = %d want %d", label, got.MaxHeight, height) + } + } +} + +// BuildFFmpegArgs — recipe shape verified by argv content. +func TestBuildFFmpegArgs_DirectPlayUsesCopy(t *testing.T) { + report := CompatibilityReport{DirectPlay: true, VideoCompat: true, AudioCompat: true} + args := BuildFFmpegArgs("/tmp/movie.mp4", report, StreamOptions{}) + joined := strings.Join(args, " ") + + want := []string{"-i /tmp/movie.mp4", "-c copy", "-movflags " + fragmentedMP4Movflags, "-f mp4", "pipe:1"} + for _, w := range want { + if !strings.Contains(joined, w) { + t.Fatalf("direct-play argv missing %q\n got: %s", w, joined) + } + } + if strings.Contains(joined, "libx264") { + t.Fatalf("direct-play must NOT invoke libx264, got: %s", joined) + } +} + +func TestBuildFFmpegArgs_TranscodeUsesLibx264(t *testing.T) { + report := CompatibilityReport{DirectPlay: false, VideoCompat: false, AudioCompat: true} + args := BuildFFmpegArgs("/tmp/m.mkv", report, StreamOptions{Quality: "720p"}) + joined := strings.Join(args, " ") + + want := []string{ + "-c:v libx264", + "scale=-2:720", + "-b:v 3500000", + "-c:a aac", + "-b:a 128000", + "-pix_fmt yuv420p", + "-preset veryfast", + } + for _, w := range want { + if !strings.Contains(joined, w) { + t.Fatalf("720p transcode argv missing %q\n got: %s", w, joined) + } + } +} + +func TestBuildFFmpegArgs_NVENCSwapsEncoder(t *testing.T) { + report := CompatibilityReport{DirectPlay: false} + args := BuildFFmpegArgs("/tmp/m.mkv", report, StreamOptions{HW: HWAccelNVENC}) + joined := strings.Join(args, " ") + + if !strings.Contains(joined, "-c:v h264_nvenc") { + t.Fatalf("NVENC must use h264_nvenc, got: %s", joined) + } + if strings.Contains(joined, "-preset veryfast") { + t.Fatalf("HW accel skips libx264 preset, got: %s", joined) + } +} + +func TestBuildFFmpegArgs_VAAPIInjectsHwaccelDecoder(t *testing.T) { + report := CompatibilityReport{DirectPlay: false} + args := BuildFFmpegArgs("/tmp/m.mkv", report, StreamOptions{HW: HWAccelVAAPI}) + joined := strings.Join(args, " ") + + if !strings.Contains(joined, "-hwaccel vaapi") { + t.Fatalf("VAAPI must add -hwaccel vaapi, got: %s", joined) + } + if !strings.Contains(joined, "scale_vaapi") { + t.Fatalf("VAAPI must use scale_vaapi filter, got: %s", joined) + } +} + +func TestBuildFFmpegArgs_StartOffsetEmitsSS(t *testing.T) { + report := CompatibilityReport{DirectPlay: true} + args := BuildFFmpegArgs("/tmp/m.mp4", report, StreamOptions{StartOffset: 65*time.Second + 500*time.Millisecond}) + joined := strings.Join(args, " ") + + if !strings.Contains(joined, "-ss 00:01:05.500") { + t.Fatalf("expected -ss 00:01:05.500, got: %s", joined) + } +} + +// HWAccel encoders. +func TestHWAccel_VideoEncoder(t *testing.T) { + cases := map[HWAccel]string{ + HWAccelNone: "libx264", + HWAccelUnset: "libx264", + HWAccelNVENC: "h264_nvenc", + HWAccelQSV: "h264_qsv", + HWAccelVAAPI: "h264_vaapi", + HWAccelVideoToolbox: "h264_videotoolbox", + } + for hw, want := range cases { + if got := hw.VideoEncoder(); got != want { + t.Errorf("%s.VideoEncoder() = %q want %q", hw, got, want) + } + } +} + +func TestHWAccel_OnlyVAAPIHasDecoder(t *testing.T) { + for _, h := range []HWAccel{HWAccelNone, HWAccelNVENC, HWAccelQSV, HWAccelVideoToolbox} { + if h.HasDecoder() { + t.Errorf("%s shouldn't claim HW decoder", h) + } + } + if !HWAccelVAAPI.HasDecoder() { + t.Error("VAAPI should claim HW decoder") + } +} + +// formatDuration — boundary cases. +func TestFormatDuration(t *testing.T) { + cases := []struct { + in time.Duration + want string + }{ + {0, "00:00:00.000"}, + {500 * time.Millisecond, "00:00:00.500"}, + {65 * time.Second, "00:01:05.000"}, + {2*time.Hour + 3*time.Minute + 7*time.Second + 250*time.Millisecond, "02:03:07.250"}, + {-time.Second, "00:00:00.000"}, + } + for _, c := range cases { + if got := formatDuration(c.in); got != c.want { + t.Errorf("formatDuration(%v) = %q want %q", c.in, got, c.want) + } + } +} + +// cappedBuffer — overflow keeps only the tail. +func TestCappedBuffer_KeepsTail(t *testing.T) { + b := newCappedBuffer(10) + b.Write([]byte("hello ")) + b.Write([]byte("world")) + b.Write([]byte("!")) + // "hello " + "world" + "!" = 12 bytes; cap 10 → keep last 10 = "llo world!". + got := b.String() + if got != "llo world!" { + t.Fatalf("unexpected tail %q", got) + } +} + +func TestCappedBuffer_LargeSingleWrite(t *testing.T) { + b := newCappedBuffer(5) + b.Write([]byte("abcdefghij")) + if got := b.String(); got != "fghij" { + t.Fatalf("large write tail wrong: %q", got) + } +} + +// NewTranscoder rejects empty paths. +func TestNewTranscoder_RequiresBothBinaries(t *testing.T) { + if _, err := NewTranscoder("", "/usr/bin/ffprobe"); err == nil { + t.Error("expected error for empty ffmpeg path") + } + if _, err := NewTranscoder("/usr/bin/ffmpeg", ""); err == nil { + t.Error("expected error for empty ffprobe path") + } + if _, err := NewTranscoder("/usr/bin/ffmpeg", "/usr/bin/ffprobe"); err != nil { + t.Errorf("valid paths should not error: %v", err) + } +} From c2e992516259bd069ea25dc47104c11a2681be9e Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 11:35:52 +0200 Subject: [PATCH 15/89] test(streaming): integration tests with real ffmpeg (skipped without it) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three end-to-end checks that the transcoder actually produces playable output, not just plausible argv. Skip cleanly on hosts without ffmpeg on PATH so unit-test CI keeps working. - TestTranscoder_DirectPlayProducesH264 — synth h264+aac MP4 via `ffmpeg -f lavfi testsrc/sine`, run Analyze (expect direct play), Stream to disk, ffprobe the result, assert codecs are still h264+aac. - TestTranscoder_TranscodeHEVCToH264 — synth hevc+ac3 MKV, expect transcode decision, Stream to memory, ffprobe-verify the output is h264+aac. Skipped if libx265 isn't compiled in. - TestTranscoder_AnalyzeReportsRealMediaInfo — sanity check that Analyze returns a usable mediainfo (320x240, ~2s duration) the API handler can show to the player. Verified locally: PASS: TestTranscoder_DirectPlayProducesH264 (0.09s) PASS: TestTranscoder_TranscodeHEVCToH264 (0.22s) PASS: TestTranscoder_AnalyzeReportsRealMediaInfo (0.06s) --- internal/streaming/integration_test.go | 204 +++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 internal/streaming/integration_test.go diff --git a/internal/streaming/integration_test.go b/internal/streaming/integration_test.go new file mode 100644 index 0000000..2cd0b21 --- /dev/null +++ b/internal/streaming/integration_test.go @@ -0,0 +1,204 @@ +package streaming + +import ( + "bytes" + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/torrentclaw/unarr/internal/library/mediainfo" +) + +// These tests need a real ffmpeg + ffprobe on PATH. They're skipped on +// CI runners that lack them — the unit tests already pin the recipes +// deterministically. Run locally when changing the transcoder pipeline. + +func resolveBins(t *testing.T) (string, string) { + t.Helper() + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("ffmpeg not on PATH — skipping integration test") + } + ffprobe, err := exec.LookPath("ffprobe") + if err != nil { + t.Skip("ffprobe not on PATH — skipping integration test") + } + return ffmpeg, ffprobe +} + +// generateTestVideo synthesises a short MP4 for the transcoder to chew on. +// vcodec/acodec let us exercise both direct-play and transcode branches. +func generateTestVideo(t *testing.T, ffmpeg, dir, vcodec, acodec, container string) string { + t.Helper() + out := filepath.Join(dir, "sample."+container) + args := []string{ + "-hide_banner", "-loglevel", "error", "-y", + "-f", "lavfi", "-i", "testsrc=duration=2:size=320x240:rate=15", + "-f", "lavfi", "-i", "sine=frequency=440:duration=2", + "-c:v", vcodec, + } + // libx265 needs at least one keyframe; 2s @ 15fps is fine. + if vcodec == "libx265" { + args = append(args, "-x265-params", "log-level=error") + } + args = append(args, "-c:a", acodec, "-shortest", out) + cmd := exec.Command(ffmpeg, args...) + if buf, err := cmd.CombinedOutput(); err != nil { + t.Skipf("could not synthesise test video (%s/%s/%s): %v\n%s", + vcodec, acodec, container, err, buf) + } + return out +} + +// probeOutput uses ffprobe to inspect the (synthesised) transcoder output +// and returns video + audio codec names. +func probeOutput(t *testing.T, ffprobe, path string) (string, string) { + t.Helper() + cmd := exec.Command(ffprobe, + "-hide_banner", "-loglevel", "error", + "-print_format", "json", "-show_streams", path) + buf, err := cmd.Output() + if err != nil { + t.Fatalf("ffprobe %s: %v", path, err) + } + var data struct { + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + } `json:"streams"` + } + if err := json.Unmarshal(buf, &data); err != nil { + t.Fatalf("ffprobe parse: %v", err) + } + var v, a string + for _, s := range data.Streams { + switch s.CodecType { + case "video": + v = s.CodecName + case "audio": + a = s.CodecName + } + } + return v, a +} + +// TestTranscoder_DirectPlayProducesH264 — H.264 + AAC source → direct play +// → output keeps both codecs, just remuxed to fMP4. +func TestTranscoder_DirectPlayProducesH264(t *testing.T) { + ffmpeg, ffprobe := resolveBins(t) + dir := t.TempDir() + src := generateTestVideo(t, ffmpeg, dir, "libx264", "aac", "mp4") + + tr, err := NewTranscoder(ffmpeg, ffprobe) + if err != nil { + t.Fatalf("NewTranscoder: %v", err) + } + + report, _, err := tr.Analyze(context.Background(), src) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if !report.DirectPlay { + t.Fatalf("h264+aac sample should be direct-playable, got %+v", report) + } + + out := filepath.Join(dir, "out.mp4") + f, err := os.Create(out) + if err != nil { + t.Fatalf("create out: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := tr.Stream(ctx, src, f, StreamOptions{HW: HWAccelNone}); err != nil { + f.Close() + t.Fatalf("Stream: %v", err) + } + f.Close() + + v, a := probeOutput(t, ffprobe, out) + if v != "h264" { + t.Fatalf("direct-play output video codec = %q want h264", v) + } + if a != "aac" { + t.Fatalf("direct-play output audio codec = %q want aac", a) + } +} + +// TestTranscoder_TranscodeHEVCToH264 — HEVC source → transcode → +// output is H.264 + AAC ready for the browser. +func TestTranscoder_TranscodeHEVCToH264(t *testing.T) { + ffmpeg, ffprobe := resolveBins(t) + dir := t.TempDir() + + // Verify libx265 available; some Alpine builds disable it. + if !encoderAvailable(context.Background(), ffmpeg, "libx265") { + t.Skip("ffmpeg lacks libx265 — skipping HEVC transcode integration") + } + src := generateTestVideo(t, ffmpeg, dir, "libx265", "ac3", "mkv") + + tr, err := NewTranscoder(ffmpeg, ffprobe) + if err != nil { + t.Fatalf("NewTranscoder: %v", err) + } + report, _, err := tr.Analyze(context.Background(), src) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if report.DirectPlay { + t.Fatalf("hevc+ac3 sample must NOT be direct-playable") + } + + var buf bytes.Buffer + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + if err := tr.Stream(ctx, src, &buf, StreamOptions{Quality: "480p", HW: HWAccelNone}); err != nil { + t.Fatalf("Stream: %v", err) + } + + out := filepath.Join(dir, "transcoded.mp4") + if err := os.WriteFile(out, buf.Bytes(), 0o644); err != nil { + t.Fatalf("persist transcode: %v", err) + } + + v, a := probeOutput(t, ffprobe, out) + if v != "h264" { + t.Fatalf("transcoded video codec = %q want h264", v) + } + if a != "aac" { + t.Fatalf("transcoded audio codec = %q want aac", a) + } +} + +// TestTranscoder_AnalyzeReportsRealMediaInfo validates that the Transcoder +// returns a usable MediaInfo on top of the report — the API handler will +// surface duration / resolution to the player UI. +func TestTranscoder_AnalyzeReportsRealMediaInfo(t *testing.T) { + ffmpeg, ffprobe := resolveBins(t) + dir := t.TempDir() + src := generateTestVideo(t, ffmpeg, dir, "libx264", "aac", "mp4") + + tr, err := NewTranscoder(ffmpeg, ffprobe) + if err != nil { + t.Fatalf("NewTranscoder: %v", err) + } + _, info, err := tr.Analyze(context.Background(), src) + if err != nil { + t.Fatalf("Analyze: %v", err) + } + if info == nil || info.Video == nil { + t.Fatalf("missing parsed mediainfo: %+v", info) + } + if info.Video.Width != 320 || info.Video.Height != 240 { + t.Errorf("dimensions = %dx%d want 320x240", info.Video.Width, info.Video.Height) + } + if info.Video.Duration < 1.5 || info.Video.Duration > 2.5 { + t.Errorf("duration ~2s expected, got %v", info.Video.Duration) + } + // Ensure the package types line up with mediainfo's exported model. + _ = mediainfo.MediaInfo{} +} From 2aeabe6b509b03a55b08e525bf76b717c25706bd Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 6 May 2026 14:46:38 +0200 Subject: [PATCH 16/89] =?UTF-8?q?feat(wstracker-probe):=20-seed=20FILE=20m?= =?UTF-8?q?ode=20for=20browser=20=E2=86=94=20unarr=20e2e=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the probe binary so it can do more than verify tracker reach: when given a real file, it builds a single-file torrent in memory, seeds it via the WebTorrent peer wire, and prints the magnet URI (with the WSS tracker injected). Useful for proving the end-to-end streaming path before any actual unarr daemon work lands. Internally uses anacrolix/torrent's metainfo.Info.BuildFromFilePath + bencode.Marshal to mint InfoBytes, then AddTorrent → seed loop. Piece length picked from a libtorrent-like ladder (16 KiB → 4 MiB) so the resulting torrent is interoperable with mainstream clients. Validation: synthesised a 5 s 320×240 H.264+AAC mp4 with ffmpeg (`testsrc + sine`), seeded it via this binary against the production wss://tracker.torrentclaw.com endpoint, opened the in-browser player at /stream/. Browser reported `downloaded: 105 KB / 105 KB` and rendered a working