unarr/internal/engine/hwaccel.go

274 lines
8.7 KiB
Go
Raw Permalink Normal View History

package engine
import (
"context"
"os"
"os/exec"
"runtime"
"strings"
"sync"
)
// HWAccel identifies a hardware-accelerated ffmpeg encoder family.
type HWAccel string
const (
HWAccelNone HWAccel = "none"
HWAccelNVENC HWAccel = "nvenc" // NVIDIA — h264_nvenc / hevc_nvenc
HWAccelQSV HWAccel = "qsv" // Intel Quick Sync — h264_qsv / hevc_qsv
HWAccelVAAPI HWAccel = "vaapi" // Linux open-source — h264_vaapi / hevc_vaapi
HWAccelVideoToolbox HWAccel = "videotoolbox" // macOS — h264_videotoolbox
)
var (
hwOnce sync.Once
hwCache HWAccel
)
// DetectHWAccel returns the most capable hardware encoder available on this
// host, or HWAccelNone if software-only. Cached after first call — adding /
// removing a GPU at runtime is rare and the cost of probing isn't free.
func DetectHWAccel(ctx context.Context, ffmpegPath string) HWAccel {
hwOnce.Do(func() {
hwCache = detectHWAccelFresh(ctx, ffmpegPath)
})
return hwCache
}
// ResetHWAccelCache clears the singleton — only used in tests.
func ResetHWAccelCache() {
hwOnce = sync.Once{}
hwCache = ""
}
func detectHWAccelFresh(ctx context.Context, ffmpegPath string) HWAccel {
if ffmpegPath == "" {
return HWAccelNone
}
encoders := listFFmpegEncoders(ctx, ffmpegPath)
if encoders == "" {
return HWAccelNone
}
// macOS — VideoToolbox is always available on Apple Silicon + recent Intel.
if runtime.GOOS == "darwin" && strings.Contains(encoders, "h264_videotoolbox") {
return HWAccelVideoToolbox
}
// NVIDIA — encoder presence + a CUDA-capable device. We rely on the
// existence of the device file rather than running nvidia-smi to keep
// startup quick on hosts without nvidia tooling.
if strings.Contains(encoders, "h264_nvenc") &&
(fileExists("/dev/nvidia0") || hasNvidiaDriver()) {
return HWAccelNVENC
}
// Intel Quick Sync — needs /dev/dri (also used by VA-API). Distinguish by
// checking whether the QSV-specific encoder is built in.
if strings.Contains(encoders, "h264_qsv") && fileExists("/dev/dri/renderD128") {
return HWAccelQSV
}
// Linux generic VA-API — works on Intel + AMD with mesa drivers.
if strings.Contains(encoders, "h264_vaapi") && fileExists("/dev/dri/renderD128") {
return HWAccelVAAPI
}
return HWAccelNone
}
func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-encoders")
out, err := cmd.CombinedOutput()
if err != nil {
return ""
}
return string(out)
}
// HWAccelDiagnostic bundles what we know about the host's ffmpeg + HW encode
// capabilities so the daemon can log a single coherent line at startup and the
// web side can surface "this agent is software-only" without re-running probes.
type HWAccelDiagnostic struct {
Pick HWAccel // backend selected by DetectHWAccel
FFmpegPath string // resolved ffmpeg binary
FFmpegVersion string // first line of `ffmpeg -version` (e.g. "ffmpeg version 6.1.1")
Encoders []string // HW + libsvtav1/libvpx9-class encoders found in -encoders output
Devices []string // device files / drivers detected at probe time
}
// DetectHWAccelDiagnostic returns the full diagnostic picture for the host's
// transcode pipeline. Unlike DetectHWAccel, this is NOT cached — callers pay
// for an ffmpeg subprocess on each call (one `-encoders`, one `-version`).
// Daemon startup is the natural caller; per-session lookups should keep using
// DetectHWAccel (cached) and only re-probe diagnostics if the user runs an
// explicit doctor command.
func DetectHWAccelDiagnostic(ctx context.Context, ffmpegPath string) HWAccelDiagnostic {
d := HWAccelDiagnostic{Pick: HWAccelNone, FFmpegPath: ffmpegPath}
if ffmpegPath == "" {
return d
}
d.FFmpegVersion = ffmpegVersionLine(ctx, ffmpegPath)
encoders := listFFmpegEncoders(ctx, ffmpegPath)
for _, name := range hwEncoderNames {
if strings.Contains(encoders, name) {
d.Encoders = append(d.Encoders, name)
}
}
// Device-file checks mirror the picks below so the log line tells the
// reader why a present encoder might still have been rejected (e.g. NVENC
// compiled in but /dev/nvidia0 missing inside a container).
if fileExists("/dev/nvidia0") {
d.Devices = append(d.Devices, "/dev/nvidia0")
}
if fileExists("/dev/dri/renderD128") {
d.Devices = append(d.Devices, "/dev/dri/renderD128")
}
if hasNvidiaDriver() {
d.Devices = append(d.Devices, "nvidia-smi")
}
d.Pick = DetectHWAccel(ctx, ffmpegPath)
return d
}
// LogLine returns a one-line human-readable summary of the diagnostic,
// suitable for daemon startup output. Format:
//
// "[transcode] ffmpeg 6.1.1 at /usr/bin/ffmpeg, HW=nvenc (h264_nvenc), devices=/dev/nvidia0,nvidia-smi"
// "[transcode] ffmpeg 6.1.1 at /home/linuxbrew/.../ffmpeg, HW=none (software libx264) — no HW encoders compiled in"
func (d HWAccelDiagnostic) LogLine() string {
var b strings.Builder
b.WriteString("[transcode] ")
if d.FFmpegVersion != "" {
b.WriteString(d.FFmpegVersion)
} else {
b.WriteString("ffmpeg")
}
if d.FFmpegPath != "" {
b.WriteString(" at ")
b.WriteString(d.FFmpegPath)
}
b.WriteString(", HW=")
b.WriteString(string(d.Pick))
if d.Pick == HWAccelNone {
if len(d.Encoders) == 0 {
b.WriteString(" (software libx264) — no HW encoders compiled in")
} else {
b.WriteString(" (software libx264) — encoders found but no matching device: ")
b.WriteString(strings.Join(d.Encoders, ","))
}
} else {
b.WriteString(" (")
b.WriteString(d.Pick.FFmpegVideoCodec("h264"))
b.WriteString(")")
if len(d.Devices) > 0 {
b.WriteString(", devices=")
b.WriteString(strings.Join(d.Devices, ","))
}
}
return b.String()
}
// hwEncoderNames lists the HW-accelerated encoders we care about for the
// startup log. Kept in lookup order so the output reads predictably across
// hosts.
var hwEncoderNames = []string{
"h264_nvenc", "hevc_nvenc",
"h264_qsv", "hevc_qsv",
"h264_vaapi", "hevc_vaapi",
"h264_videotoolbox", "hevc_videotoolbox",
}
// ffmpegVersionLine extracts the "ffmpeg version X.Y.Z" prefix from
// `ffmpeg -version`. Bounded to avoid hanging the daemon on a misbehaving
// binary.
func ffmpegVersionLine(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-version")
out, err := cmd.CombinedOutput()
if err != nil || len(out) == 0 {
return ""
}
line, _, _ := strings.Cut(string(out), "\n")
// "ffmpeg version 6.1.1-some-build-suffix Copyright..." → keep up to first
// space after "version 6.x" to avoid spamming build flags into the log.
if idx := strings.Index(line, "Copyright"); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
return strings.TrimSpace(line)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func hasNvidiaDriver() bool {
// Cheap proxy — if the user has nvidia-smi on PATH they presumably also
// have a working driver / runtime libraries.
_, err := exec.LookPath("nvidia-smi")
return err == nil
}
// FFmpegVideoCodec returns the encoder name to pass to `-c:v` for the
// requested HW accel + target (h264 or hevc).
func (h HWAccel) FFmpegVideoCodec(target string) string {
target = strings.ToLower(target)
switch h {
case HWAccelNVENC:
if target == "hevc" {
return "hevc_nvenc"
}
return "h264_nvenc"
case HWAccelQSV:
if target == "hevc" {
return "hevc_qsv"
}
return "h264_qsv"
case HWAccelVAAPI:
if target == "hevc" {
return "hevc_vaapi"
}
return "h264_vaapi"
case HWAccelVideoToolbox:
if target == "hevc" {
return "hevc_videotoolbox"
}
return "h264_videotoolbox"
default:
// Software fallback. libx264 ships with every ffmpeg build.
return "libx264"
}
}
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
2026-05-26 20:39:57 +02:00
// H264LevelForHeight returns the lowest H.264 profile level capable of
// encoding a stream at the given output pixel height. Each tier carries
// enough macroblock headroom to handle ANAMORPHIC content (up to ~2.4:1
// cinemascope) at 30 fps — a fixed 16:9 assumption used to silently bust
// the level on a 720p movie shot in 2.4:1 (1728×720 = 4860 MBs > 3.1's
// 3600 limit; libx264 logs "frame MB size > level limit" and emits a
// corrupt stream).
func H264LevelForHeight(height int) string {
switch {
case height <= 0:
// Unknown source — pick a level that covers up to 4K so we never
// re-introduce the silent-failure mode that motivated this helper.
return "5.1"
case height <= 480:
return "3.1"
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
2026-05-26 20:39:57 +02:00
case height <= 720:
// 4.0 instead of 3.1: covers 720p anamorphic (e.g. 1728×720) +
// MB rate up to 245k/s (3.1 caps at 108k/s — broken at 24 fps).
return "4.0"
feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5) Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`) so the in-browser player on torrentclaw.com plays cross-network without Tailscale or port forwarding — the mixed-content block that was breaking HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare, never through TorrentClaw infra (preserves the aggregator legal posture). New surface: • `internal/funnel/` package: subprocess wrapper + auto-download for cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases on first run, validated by ELF magic + size sanity, O_EXCL partial write so concurrent daemons don't clobber each other. • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`). • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit the reported URL is cleared so the web stops handing out a dead host. • Wire: agent registers/syncs a FunnelURL field; web prefers it over Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio addon). Default ON for fresh installs (NAS/Docker get it without terminal-in); existing configs that pre-date the feature stay off until the operator opts in with `unarr funnel on`. Docker image now bundles cloudflared (built per TARGETARCH via buildx). Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9 sources. The level we hint to libx264 was derived from height alone, which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's 3600). Bumped each tier: 720p → 4.0, 1080p → 4.1. Version: 0.9.4 → 0.9.5.
2026-05-26 20:39:57 +02:00
case height <= 1080:
// 4.1 instead of 4.0: covers 1080p anamorphic + 30 fps (~245k MBs/s).
return "4.1"
case height <= 1440:
return "5.0"
case height <= 2160:
return "5.1"
default:
// 4K @ 60 fps and 8K all fall under 6.x.
return "6.0"
}
}