feat(playback): balance transcode sessions across multiple GPUs#425
feat(playback): balance transcode sessions across multiple GPUs#425RXWatcher wants to merge 10 commits into
Conversation
📝 WalkthroughWalkthroughAdds comma-separated GPU device configuration with load-aware reservation and release, expands hardware detection with device descriptions, aggregates hardware inventories across transcode nodes, and adds an admin UI for selecting and comparing render devices. ChangesGPU device balancing
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant Admin
participant PlaybackSettings
participant HandleHWAccel
participant TranscodeNodes
Admin->>PlaybackSettings: open playback settings
PlaybackSettings->>HandleHWAccel: request hardware inventory
HandleHWAccel->>TranscodeNodes: probe enabled healthy nodes
TranscodeNodes-->>HandleHWAccel: return device paths and details
HandleHWAccel-->>PlaybackSettings: return aggregate inventory
PlaybackSettings-->>Admin: render device rows and warnings
sequenceDiagram
participant TranscodeRequest
participant StartTranscode
participant AcquireHWDevice
participant ffmpeg
participant TranscodeSession
TranscodeRequest->>StartTranscode: start with configured device list
StartTranscode->>AcquireHWDevice: select and reserve one device
StartTranscode->>ffmpeg: launch with resolved device
ffmpeg-->>TranscodeSession: execute workload
TranscodeSession->>AcquireHWDevice: release reservation after exit
Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/playback/hwdevicebalance.go`:
- Around line 98-105: Make hardware-device selection and reservation atomic in
the resolveSessionHWDevice flow: perform device availability checks before
acquiring hwDeviceLoad.mu, then select the least-loaded device and increment its
count within one critical section. Preserve the noop path for non-render-device
acceleration while ensuring concurrent sessions observe prior reservations;
reuse or extract the selection logic used by selectLeastLoadedHWDevice as
appropriate.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: ca8d6a6c-393c-4b39-bb70-aebac1ccda04
📒 Files selected for processing (5)
internal/config/config.gointernal/playback/gpudetect.gointernal/playback/hwdevicebalance.gointernal/playback/hwdevicebalance_test.gointernal/playback/transcode.go
| device := selectLeastLoadedHWDevice(devices) | ||
| if !hwAccelUsesRenderDevice(hwAccel) { | ||
| return device, noop | ||
| } | ||
| hwDeviceLoad.mu.Lock() | ||
| hwDeviceLoad.counts[device]++ | ||
| count := hwDeviceLoad.counts[device] | ||
| hwDeviceLoad.mu.Unlock() |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Time-of-check to time-of-use (TOCTOU) race condition in load balancing.
The selectLeastLoadedHWDevice function reads the load counts, but resolveSessionHWDevice separately acquires a lock to increment the count later. If multiple transcode sessions spawn concurrently, they will all read the same initial counts and select the same GPU before any of them commit the increment. This degrades the load balancer during bursty traffic by piling concurrent requests onto one device.
Consider performing the load evaluation and the increment atomically within a single hwDeviceLoad.mu critical section to ensure each concurrent request correctly sees the reservations made by the preceding ones.
💡 Example of atomic selection and acquisition
You can perform the os.Stat checks outside the lock, and then select and increment under a single lock:
isHW := hwAccelUsesRenderDevice(hwAccel)
// Filter present devices without holding the lock
present := make([]string, 0, len(devices))
for _, device := range devices {
if hwDeviceStat(device) == nil {
present = append(present, device)
}
}
hwDeviceLoad.mu.Lock()
device := devices[0]
if len(present) > 0 {
device = present[0]
for _, d := range present[1:] {
if hwDeviceLoad.counts[d] < hwDeviceLoad.counts[device] {
device = d
}
}
}
if !isHW {
hwDeviceLoad.mu.Unlock()
return device, noop
}
hwDeviceLoad.counts[device]++
count := hwDeviceLoad.counts[device]
hwDeviceLoad.mu.Unlock()(You may want to extract the inner comparison loop to a small private helper to keep it reusable for PickRenderDevice.)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/playback/hwdevicebalance.go` around lines 98 - 105, Make
hardware-device selection and reservation atomic in the resolveSessionHWDevice
flow: perform device availability checks before acquiring hwDeviceLoad.mu, then
select the least-loaded device and increment its count within one critical
section. Preserve the noop path for non-render-device acceleration while
ensuring concurrent sessions observe prior reservations; reuse or extract the
selection logic used by selectLeastLoadedHWDevice as appropriate.
|
Added the admin UI on top: the hw-accel detection endpoint now reports |
|
Addressed the atomicity finding in b8595cc: presence checks moved outside the lock, selection + reservation now share one critical section, and a new |
Quick104
left a comment
There was a problem hiding this comment.
The architectural direction is sound: a transcode node should be able to schedule work across the compatible GPUs local to that host, rather than requiring one daemon per GPU. Multiple nodes should remain available for host/process isolation and heterogeneous policies.
I’m requesting changes because the current implementation does not apply that model consistently and is not safe across the full advertised surface.
Please address all of the following before this is approved:
-
Make normal transcode-node sessions use the device pool. Fresh
/transcode/startrequests currently constructTranscodeOptswithHWDevice: "", so they auto-detect the first GPU and bypass the configured list. Reconstruction usescfg.Playback.HWDevice, which makes fresh and reconstructed sessions inconsistent. -
Use one shared acquisition path for every GPU workload. Chapter thumbnails currently pass a non-empty comma-separated value directly to FFmpeg as one device path. Downloads are only best-effort and are not represented in the same active-load accounting. Streaming, prepared downloads, and thumbnails should all resolve exactly one compatible device immediately before process launch and release it at the correct lifecycle boundary.
-
Model accelerator compatibility and device identity correctly. Presence plus session count is not enough. The UI exposes every DRM render node for QSV, VAAPI, NVENC, and auto, so mixed-vendor selections can pair an accelerator with an incompatible GPU. CUDA/NVENC device selection uses an index or UUID, not a
/dev/dri/renderD*path; the current filesystem-presence filter also makes a manual0,1list collapse to device 0. Either explicitly constrain this first version to homogeneous compatible QSV/VAAPI render devices or introduce accelerator-specific device identities/capabilities. -
Hold reservations until FFmpeg has actually stopped.
shutdownreleases the GPU before cancelling the process and waiting fordone, so another start can select the supposedly free device while the old FFmpeg process is still running. -
Fix the multi-node configuration model. The admin endpoint reports whichever healthy node currently has the fewest jobs, but saving writes one cluster-wide
playback.hw_devicevalue consumed by every node. This is only correct under an undocumented identical-device-path assumption. Make inventory/configuration node-aware, or explicitly design and validate the homogeneous-path contract. -
Keep configured devices visible when detection is empty or unavailable. The picker is gated on
renderDevices.length > 0, so all configured-but-missing devices disappear when every device is absent or an older node does not returnrender_device_details. -
Simplify ownership. Keeping CSV as backward-compatible storage is reasonable, but parse it once into a typed internal device set. The allocator/reservation logic should have one small ownership boundary; sysfs inventory and operator-facing descriptions should remain with GPU detection rather than sharing
hwdevicebalance.go. -
Add integration-level coverage for the real call paths. Please cover fresh transcode-node starts, chapter-thumbnail argument construction from a list, accelerator/device compatibility (including NVENC behavior or an explicit rejection), reservation release after process exit, all-devices-missing UI behavior, and heterogeneous node inventories. The current allocator unit tests and race test pass but do not exercise the paths where these failures occur.
-
Complete the repository gates and PR requirements.
pnpm run format:checkcurrently fails on the three changed TS/TSX files. Please also link the relevant scope issue/capability item, add a screenshot or recording of the admin UI, and document the supported topology/compatibility assumptions.
Once these are addressed, the result should be both simpler operationally than one process per GPU and consistent with the existing node-pool architecture.
|
All nine items addressed in 8bee418…d08b162e; the PR description now carries the supported-topology section and a per-item map. The load-bearing changes:
Scope issue: #449. Formatting/lint/vet green; test inventory is in the PR body. UI screenshot to follow. |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
web/src/pages/admin-settings/playbackSettings.utils.ts (1)
97-106: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winPicker rows are limited to the primary (first-responding) node's devices.
detectedDevicesreads only the flatrender_device_details/render_devicesfields (the first successful probe), neverdetection.nodes[*]. A device present only on a secondary node has no row and can't be selected — the admin's only way to select it is if it's already in the storedhw_devicevalue. This seems to match the stated design ("rows combine configured and detected devices"), so flagging for awareness rather than as a defect: consider whether the picker should offer the union of all responding nodes' devices (with the existing "missing on" warning covering the divergence case) so admins can discover node-specific hardware without pre-existing config.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@web/src/pages/admin-settings/playbackSettings.utils.ts` around lines 97 - 106, Consider updating detectedDevices to include devices from every responding entry in detection.nodes, while retaining configured-device rows and existing missing-on warning behavior. Combine node-level render device details and legacy render_devices into a deduplicated list, preserving the current primary-node fallback when node data is unavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/chapterthumbs/service_test.go`:
- Around line 680-704: Update the device fixture in the test around
service.extractFrame so it does not depend on hardcoded host paths under
/dev/dri. Use unique nonexistent paths created beneath t.TempDir(), or stub the
device-selection behavior, while preserving the assertions that ffmpeg receives
one resolved device and never the raw comma-separated list.
---
Nitpick comments:
In `@web/src/pages/admin-settings/playbackSettings.utils.ts`:
- Around line 97-106: Consider updating detectedDevices to include devices from
every responding entry in detection.nodes, while retaining configured-device
rows and existing missing-on warning behavior. Combine node-level render device
details and legacy render_devices into a deduplicated list, preserving the
current primary-node fallback when node data is unavailable.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: f9b1aec1-7074-4650-97e3-4eba7b269537
📒 Files selected for processing (19)
internal/api/handlers/system.gointernal/api/handlers/system_test.gointernal/chapterthumbs/extractor.gointernal/chapterthumbs/service.gointernal/chapterthumbs/service_test.gointernal/config/config.gointernal/playback/gpudetect.gointernal/playback/hwdevice.gointernal/playback/hwdevice_test.gointernal/playback/prepare_file.gointernal/playback/prepare_file_test.gointernal/playback/transcode.gointernal/playback/transcode_hwdevice_test.gointernal/transcodenode/server.gointernal/transcodenode/server_test.goweb/src/hooks/queries/admin/system.tsweb/src/pages/admin-settings/PlaybackSettings.tsxweb/src/pages/admin-settings/playbackSettings.utils.test.tsweb/src/pages/admin-settings/playbackSettings.utils.ts
| hwAccel: "vaapi", | ||
| hwDevice: "/dev/dri/renderD888,/dev/dri/renderD889", | ||
| } | ||
| file := &models.MediaFile{FilePath: "/media/movie.mkv"} | ||
|
|
||
| var hwArgs []string | ||
| service.runFFmpegFrameExtractFunc = func(_ context.Context, _ string, args []string) ([]byte, error) { | ||
| if hwArgs == nil { | ||
| hwArgs = append([]string(nil), args...) | ||
| } | ||
| return []byte("frame"), nil | ||
| } | ||
|
|
||
| if _, _, err := service.extractFrame(context.Background(), file, 5, chapterThumbnailHDRPolicyBestEffort); err != nil { | ||
| t.Fatalf("extractFrame() error = %v", err) | ||
| } | ||
|
|
||
| joined := strings.Join(hwArgs, " ") | ||
| // Neither test device exists, so the balancer deterministically falls back | ||
| // to the first entry — but never passes the raw list to ffmpeg. | ||
| if strings.Contains(joined, "/dev/dri/renderD888,/dev/dri/renderD889") { | ||
| t.Fatalf("ffmpeg args contain the raw device list:\n%s", joined) | ||
| } | ||
| if !strings.Contains(joined, "/dev/dri/renderD888") { | ||
| t.Fatalf("ffmpeg args missing a resolved device:\n%s", joined) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the device fixture independent of host state.
The test assumes both hardcoded paths are absent, but that is not enforced. If /dev/dri/renderD889 exists while renderD888 does not, the balancer can correctly select renderD889 and this test fails. Use unique paths under t.TempDir() or stub device selection so the expected fallback is deterministic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/chapterthumbs/service_test.go` around lines 680 - 704, Update the
device fixture in the test around service.extractFrame so it does not depend on
hardcoded host paths under /dev/dri. Use unique nonexistent paths created
beneath t.TempDir(), or stub the device-selection behavior, while preserving the
assertions that ffmpeg receives one resolved device and never the raw
comma-separated list.
playback.hw_device now accepts a comma-separated render-device list (e.g. "/dev/dri/renderD128,/dev/dri/renderD129"). Each transcode session resolves the list to one concrete device at spawn — the present device with the fewest active GPU sessions, ties keeping list order — and holds that device for its whole lifetime (seek/audio restarts reuse it); the reservation releases on session shutdown, idempotently, including early spawn-failure paths. Software-accel sessions never reserve, so they cannot skew the balance. A single configured value keeps the historical pass-through contract and an empty value still auto-detects, so existing deployments are unaffected. PickRenderDevice is list-aware too, picking least-loaded without reserving, which lets the non-session consumers (chapter thumbnails, download artifacts, transcode nodes) spread load best-effort when given a list. Motivation: hosts with two identical media GPUs (e.g. dual Arc A310) previously pinned every session to one device while the second sat idle.
The hw-accel detection endpoint now reports render_device_details — each
render device with a human label derived from its sysfs PCI vendor/device
ids ("Intel GPU (0x56a6)") — and the Playback settings page renders them as
per-device toggles instead of requiring a hand-typed device path. No
selection means auto (first available device); one selection pins every
session; multiple selections balance least-loaded. The stored
playback.hw_device value stays the comma-separated list, written in stable
detection order regardless of click order, and a configured-but-undetected
device stays visible so a temporarily missing GPU is not silently dropped
on save.
Review follow-up: resolveSessionHWDevice previously selected the least-loaded device and incremented its count in two separate critical sections, so concurrent session starts could all pick the same device before any reservation landed. Device presence checks now happen outside the lock and selection + reservation share one critical section; a concurrency test asserts an exact split across two devices for eight simultaneous starts, which the two-step version cannot guarantee.
…ocess exit Replace the CSV-handling spread across resolveSessionHWDevice and PickRenderDevice with HWDeviceSet + AcquireHWDevice in hwdevice.go: every GPU workload resolves exactly one device immediately before spawn. Balancing is explicitly QSV/VAAPI-only — NVENC addresses GPUs by CUDA index/UUID, so a multi-entry list warns and uses the first entry instead of collapsing through the path-presence filter. Sessions now release their reservation only after ffmpeg has been reaped (shutdown waits on done first), closing the window where a new start could pick a device the old process still occupied. Render-device sysfs descriptions move to gpudetect.go so the allocator file owns only selection/reservation.
PrepareFile resolves the configured hw_device list to one concrete device via AcquireHWDevice and holds the reservation until ffmpeg exits (Run is synchronous, so the deferred release is the process-exit boundary). Download encodes now participate in the same active-load accounting as streaming sessions instead of best-effort spreading.
…red pool ExtractFrame acquires one concrete device from AcquireHWDevice for the hardware attempt (released when the attempt finishes) instead of passing the raw comma-separated value to ffmpeg as a single device path. The service stops pre-resolving and caching a device at first use — the raw configured value flows through and each extraction resolves it.
/transcode/start constructed TranscodeOpts with an empty HWDevice, so fresh sessions auto-detected the first GPU and bypassed the configured list while reconstructed sessions honored it. Both paths now feed the node-local config value into StartTranscode's shared resolution.
playback.hw_device is one cluster-wide value consumed by every transcode node, but the endpoint probed only whichever healthy node had the fewest jobs — an admin could configure devices that don't exist on the other nodes. The endpoint now probes every healthy node concurrently and returns a nodes array (URL, name, resolved accel, devices, or probe error) alongside the backward-compatible flat fields, and the config doc states the homogeneous-path contract explicitly.
…gence The picker rows are now the union of detected devices and configured entries, so configured-but-missing devices stay visible (and deselectable) when detection returns nothing or an older node omits render_device_details (plain render_devices paths fall back to a generic label). Per-node inventories from the hw-accel endpoint drive two warnings: a banner when responding nodes report different device sets, and a per-row note listing nodes missing that device. The multi-select is hidden for NVENC — balancing is QSV/VA-API only — with a notice when a multi-device value is already stored.
d08b162 to
cb2b429
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/handlers/system.go`:
- Around line 74-81: Wrap each per-node probe goroutine in the loop around
fetchRemoteHWAccel with a deferred recover, and record any recovered panic in
the corresponding errs[i] entry as a probe error while preserving the existing
infos[i] behavior. Ensure the panic is contained to that node’s probe and does
not escape the goroutine or crash the process.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 098c8ee3-f43f-4bc6-b0c5-0851cf34cad3
📒 Files selected for processing (19)
internal/api/handlers/system.gointernal/api/handlers/system_test.gointernal/chapterthumbs/extractor.gointernal/chapterthumbs/service.gointernal/chapterthumbs/service_test.gointernal/config/config.gointernal/playback/gpudetect.gointernal/playback/hwdevice.gointernal/playback/hwdevice_test.gointernal/playback/prepare_file.gointernal/playback/prepare_file_test.gointernal/playback/transcode.gointernal/playback/transcode_hwdevice_test.gointernal/transcodenode/server.gointernal/transcodenode/server_test.goweb/src/hooks/queries/admin/system.tsweb/src/pages/admin-settings/PlaybackSettings.tsxweb/src/pages/admin-settings/playbackSettings.utils.test.tsweb/src/pages/admin-settings/playbackSettings.utils.ts
🚧 Files skipped from review as they are similar to previous changes (18)
- internal/playback/prepare_file.go
- web/src/pages/admin-settings/playbackSettings.utils.test.ts
- internal/config/config.go
- web/src/pages/admin-settings/PlaybackSettings.tsx
- internal/chapterthumbs/service.go
- internal/playback/prepare_file_test.go
- internal/playback/transcode_hwdevice_test.go
- internal/chapterthumbs/extractor.go
- internal/transcodenode/server.go
- internal/api/handlers/system_test.go
- web/src/hooks/queries/admin/system.ts
- internal/playback/gpudetect.go
- web/src/pages/admin-settings/playbackSettings.utils.ts
- internal/transcodenode/server_test.go
- internal/playback/hwdevice_test.go
- internal/playback/hwdevice.go
- internal/chapterthumbs/service_test.go
- internal/playback/transcode.go
| for i, node := range healthy { | ||
| wg.Add(1) | ||
| go func() { | ||
| defer wg.Done() | ||
| infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node) | ||
| }() | ||
| } | ||
| wg.Wait() |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Recover panics inside probe goroutines.
Each per-node probe goroutine has no recover(). A panic surfaced from fetchRemoteHWAccel (e.g. malformed/unexpected JSON from a remote node, a nil-deref in the HTTP client path) will unwind unrecovered and crash the entire process — not just this request — since panics in spawned goroutines are never caught by a recover in the calling goroutine. Given this fans out to every healthy node on every admin poll, one misbehaving node is enough to take down the whole control plane.
🛡️ Proposed fix: recover and record as a probe error
for i, node := range healthy {
wg.Add(1)
- go func() {
- defer wg.Done()
- infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
- }()
+ go func() {
+ defer wg.Done()
+ defer func() {
+ if rec := recover(); rec != nil {
+ errs[i] = fmt.Errorf("panic probing node: %v", rec)
+ }
+ }()
+ infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node)
+ }()
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| for i, node := range healthy { | |
| wg.Add(1) | |
| go func() { | |
| defer wg.Done() | |
| infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node) | |
| }() | |
| } | |
| wg.Wait() | |
| for i, node := range healthy { | |
| wg.Add(1) | |
| go func() { | |
| defer wg.Done() | |
| defer func() { | |
| if rec := recover(); rec != nil { | |
| errs[i] = fmt.Errorf("panic probing node: %v", rec) | |
| } | |
| }() | |
| infos[i], errs[i] = h.fetchRemoteHWAccel(r.Context(), node) | |
| }() | |
| } | |
| wg.Wait() |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/api/handlers/system.go` around lines 74 - 81, Wrap each per-node
probe goroutine in the loop around fetchRemoteHWAccel with a deferred recover,
and record any recovered panic in the corresponding errs[i] entry as a probe
error while preserving the existing infos[i] behavior. Ensure the panic is
contained to that node’s probe and does not escape the goroutine or crash the
process.
Part of #449.
Summary
playback.hw_deviceaccepts a comma-separated render-device list (e.g./dev/dri/renderD128,/dev/dri/renderD129). Every GPU workload — streaming transcode sessions, prepared downloads, chapter thumbnails — resolves the list to exactly one concrete device through a single acquisition boundary (AcquireHWDeviceininternal/playback/hwdevice.go) immediately before ffmpeg launch: the present device with the fewest active workloads (ties keep list order). The reservation is held until that ffmpeg process has exited — sessions release after the process is reaped (seek/audio-switch restarts reuse the device), downloads and thumbnails release when their synchronous run returns.Backward compatibility is exact: a single configured value keeps the historical pass-through contract for every accelerator, and an empty value still auto-detects.
Supported topology / compatibility assumptions
playback.hw_device, so multi-node deployments must expose the listed devices at identical paths on every node (documented on the config field)./admin/system/hw-accelnow probes every healthy node and returns per-node inventories; the admin UI warns when node inventories diverge and marks devices missing on specific nodes.Review follow-ups addressed (from the changes-requested review)
/transcode/startnode sessions now feed the node-local configured value into the same resolution path as reconstruction — no more empty-device bypass.StartTranscode), downloads (PrepareFile), and chapter thumbnails (ExtractFrame) all acquire through the one shared path with load accounting; thumbnails no longer pass a raw CSV to ffmpeg as a device path.0,1→0is gone because NVENC never enters the balancer).shutdownwaits on the monitor goroutine first).render_device_details(falls back to plainrender_devicespaths).HWDeviceSetparsed once; the allocator file (hwdevice.go) owns only selection/reservation; sysfs inventory and operator-facing descriptions live with GPU detection (gpudetect.go).pnpm run format:check,pnpm run lint,tsc -b,gofmt,go vetclean on touched files.Testing
internal/playback/hwdevice_test.go: parse/acquire semantics incl. NVENC no-reserve, all-missing fallback, idempotent release, exact concurrent split under-race.internal/playback/transcode_hwdevice_test.go: reservation held untilCloseProcessreaps a real (fake-binary) ffmpeg; released on spawn failure.internal/playback/prepare_file_test.go,internal/chapterthumbs/service_test.go: one concrete device in built args, never the raw list; release after run.internal/transcodenode/server_test.go: fresh/transcode/startresolves the configured list.internal/api/handlers/system_test.go: aggregation across healthy nodes, failed-probe entries, all-probes-fail local fallback.web: 14 vitest cases for row union/fallback/divergence;pnpm run format:checkgreen.go test -racegreen forplayback,chapterthumbs,transcodenode,api/handlers(the four NVENC ffmpeg-probe tests and two jellycompat process-lock tests fail on unmodifiedmainon macOS; unrelated).Admin UI screenshot: to follow.
🤖 Generated with Claude Code
Summary by CodeRabbit
playback.hw_devicewith per-device toggles, availability, and cluster safety warnings.GET /admin/system/hw-accelto aggregate probe results across all healthy transcode nodes, including per-node render-device inventories.