feat(library): resilient scan for large libraries and better ffprobe errors

- Use a dedicated 10-minute HTTP client for library-sync so libraries
  with hundreds or thousands of items no longer time out
- Show actionable ffprobe-not-found error: detects Docker and suggests
  FFPROBE_PATH env var, config.toml setting, or package install
- Include static ffprobe binary in Docker image (johnvansickle.com)
- Bump version to 0.6.2
This commit is contained in:
Deivid Soto 2026-04-09 09:13:38 +02:00
parent 3fd19f1406
commit 228564eb7f
5 changed files with 71 additions and 7 deletions

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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"

View file

@ -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.