diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b08ce6..022a217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ 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.2] - 2026-04-08 + +### Added + +- **library**: dedicated 10-minute HTTP client for library-sync — large libraries (hundreds/thousands of items) no longer time out during scan +- **library**: actionable ffprobe-not-found error — detects Docker environment and shows install options (`FFPROBE_PATH`, `[library] ffprobe_path`, or package install) + ## [0.6.1] - 2026-04-08 ### Added diff --git a/Dockerfile b/Dockerfile index 69dbcc7..f7650f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +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. +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" ;; \ + *) 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 && \ + chmod +x /usr/local/bin/ffprobe && \ + rm -rf /tmp/ff.tar.xz /tmp/ffmpeg /tmp/ffmpeg-* && \ + ffprobe -version | head -1 + # ---- Build stage ---- FROM golang:1.25-alpine AS builder @@ -31,6 +51,7 @@ 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/internal/agent/client.go b/internal/agent/client.go index ef0be81..5ff987d 100644 --- a/internal/agent/client.go +++ b/internal/agent/client.go @@ -19,7 +19,10 @@ type Client struct { // wakeClient has no built-in timeout — used exclusively for the long-poll // wake endpoint where the context controls cancellation. wakeClient *http.Client - userAgent string + // librarySyncClient has a generous timeout for library-sync calls which can + // take several minutes when syncing hundreds or thousands of items. + librarySyncClient *http.Client + userAgent string } // NewClient creates an agent API client. @@ -33,7 +36,11 @@ func NewClient(baseURL, apiKey, userAgent string) *Client { // wakeClient has no built-in timeout — the context controls it. // The server holds the connection for up to 28s before responding. wakeClient: &http.Client{}, - userAgent: userAgent, + // librarySyncClient uses a 10-minute timeout to handle large libraries + // (hundreds or thousands of items) where ffprobe scanning alone can take + // several minutes before the HTTP request is even sent. + librarySyncClient: &http.Client{Timeout: 10 * time.Minute}, + userAgent: userAgent, } } @@ -165,9 +172,10 @@ func (c *Client) BatchDownload(ctx context.Context, req BatchDownloadRequest) (* } // SyncLibrary sends scanned library items to the server for matching and upgrade discovery. +// Uses a 10-minute timeout client to handle large libraries where scanning can take several minutes. func (c *Client) SyncLibrary(ctx context.Context, req LibrarySyncRequest) (*LibrarySyncResponse, error) { var resp LibrarySyncResponse - if err := c.doPost(ctx, "/api/internal/agent/library-sync", req, &resp); err != nil { + if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/library-sync", req, &resp); err != nil { return nil, fmt.Errorf("library sync: %w", err) } return &resp, nil @@ -212,8 +220,14 @@ func (c *Client) WaitForWake(ctx context.Context) (bool, error) { return result.Wake, nil } -// doPost sends a JSON POST request and decodes the response. +// doPost sends a JSON POST request using the default httpClient and decodes the response. func (c *Client) doPost(ctx context.Context, path string, body any, dst any) error { + return c.doPostWith(ctx, c.httpClient, path, body, dst) +} + +// doPostWith sends a JSON POST request using the provided HTTP client and decodes the response. +// Use this to override the default timeout for specific operations (e.g. librarySyncClient). +func (c *Client) doPostWith(ctx context.Context, hc *http.Client, path string, body any, dst any) error { jsonBody, err := json.Marshal(body) if err != nil { return fmt.Errorf("marshal body: %w", err) @@ -227,7 +241,7 @@ func (c *Client) doPost(ctx context.Context, path string, body any, dst any) err c.setHeaders(req) req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) + resp, err := hc.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 05e8fca..1b6e4dc 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.1" +var Version = "0.6.2" diff --git a/internal/library/mediainfo/ffprobe.go b/internal/library/mediainfo/ffprobe.go index 723ef6f..5b33979 100644 --- a/internal/library/mediainfo/ffprobe.go +++ b/internal/library/mediainfo/ffprobe.go @@ -251,7 +251,29 @@ func ResolveFFprobe(explicit string) (string, error) { return p, nil } - return "", fmt.Errorf("ffprobe not found. Install ffmpeg or provide --ffprobe path") + // Give an actionable error depending on whether we're running in Docker. + if isDocker() { + return "", fmt.Errorf( + "ffprobe not found and auto-download failed (read-only filesystem?).\n" + + "Options:\n" + + " • Use the official image: torrentclaw/unarr (includes ffprobe)\n" + + " • Set FFPROBE_PATH env var to point to a pre-installed ffprobe binary\n" + + " • Add to config.toml: [library]\\nffprobe_path = \"/path/to/ffprobe\"", + ) + } + return "", fmt.Errorf( + "ffprobe not found and auto-download failed.\n" + + "Options:\n" + + " • Install ffmpeg: sudo apt install ffmpeg (or brew install ffmpeg)\n" + + " • Set FFPROBE_PATH env var to point to the ffprobe binary\n" + + " • Add to config.toml: [library]\\nffprobe_path = \"/path/to/ffprobe\"", + ) +} + +// isDocker reports whether the process is running inside a Docker container. +func isDocker() bool { + _, err := os.Stat("/.dockerenv") + return err == nil } // tagValue gets a tag value case-insensitively.