* feat(playback): balance transcode sessions across multiple GPUs
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.
* feat(admin): GPU device picker for playback hw_device
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.
* fix(playback): make GPU selection and reservation atomic
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.
* refactor(playback): one typed GPU acquisition boundary, release on process 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.
* fix(downloads): prepared downloads acquire a GPU through the shared pool
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.
* fix(chapterthumbs): resolve hw_device list per extraction via the shared 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.
* fix(transcodenode): fresh starts use this node's configured hw_device
/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.
* feat(admin): node-aware GPU inventory on /admin/system/hw-accel
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.
* feat(admin): GPU picker survives empty detection, warns on node divergence
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.
* style: gofmt touched files
* fix(playback): release GPU reservations on process exit
---------
Co-authored-by: rxwatcher <rxwatcher@users.noreply.github.com>
Co-authored-by: Quick <31828688+Quick104@users.noreply.github.com>
452 lines
15 KiB
Go
452 lines
15 KiB
Go
package transcodenode
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/go-chi/chi/v5"
|
||
|
||
"github.com/Silo-Server/silo-server/internal/config"
|
||
"github.com/Silo-Server/silo-server/internal/nodeconfig"
|
||
"github.com/Silo-Server/silo-server/internal/nodesessions"
|
||
"github.com/Silo-Server/silo-server/internal/playback"
|
||
"github.com/Silo-Server/silo-server/internal/streamtoken"
|
||
)
|
||
|
||
const testSecret = "node-reconstruct-test-secret"
|
||
|
||
// newTestServer builds a transcode Server whose config carries a known JWT secret
|
||
// so reconstructFromToken can verify forwarded stream tokens. The tracker is left
|
||
// nil: the guard-rejection cases never reach the spawn/track path.
|
||
func newTestServer(t *testing.T) *Server {
|
||
t.Helper()
|
||
w := nodeconfig.NewWatcher(nil, nil, nil, nodeconfig.BootstrapOverrides{})
|
||
cfg := &config.Config{}
|
||
cfg.Auth.JWTSecret = testSecret
|
||
cfg.Playback.TranscodeDir = t.TempDir()
|
||
w.SetConfigForTest(cfg)
|
||
return &Server{
|
||
watcher: w,
|
||
sessions: make(map[string]*playback.TranscodeSession),
|
||
}
|
||
}
|
||
|
||
func TestHandleStartRequireReadyRejectsExitedFFmpeg(t *testing.T) {
|
||
server := newTestServer(t)
|
||
ffmpegPath := filepath.Join(t.TempDir(), "failing-ffmpeg.sh")
|
||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
server.watcher.Config().Playback.FFmpegPath = ffmpegPath
|
||
requestBody, err := json.Marshal(TranscodeStartRequest{
|
||
SessionID: "ready-failure-1",
|
||
InputPath: "/media/movie.mkv",
|
||
TargetCodecVideo: "h264",
|
||
TargetCodecAudio: "aac",
|
||
SegmentDuration: 2,
|
||
RequireReady: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(requestBody))
|
||
rr := httptest.NewRecorder()
|
||
server.handleStart(rr, req)
|
||
if rr.Code != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
server.mu.RLock()
|
||
_, registered := server.sessions["ready-failure-1"]
|
||
server.mu.RUnlock()
|
||
if registered {
|
||
t.Fatal("failed readiness session was registered")
|
||
}
|
||
}
|
||
|
||
func TestHandleStartDistinctReplacementFailurePreservesPredecessor(t *testing.T) {
|
||
server := newTestServer(t)
|
||
server.sessions["public-session"] = &playback.TranscodeSession{}
|
||
server.activeJobs.Store(1)
|
||
|
||
ffmpegPath := filepath.Join(t.TempDir(), "failing-ffmpeg.sh")
|
||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
server.watcher.Config().Playback.FFmpegPath = ffmpegPath
|
||
requestBody, err := json.Marshal(TranscodeStartRequest{
|
||
SessionID: "public-session-legacy-replacement",
|
||
InputPath: "/media/movie.mkv",
|
||
TargetCodecVideo: "copy",
|
||
TargetCodecAudio: "aac",
|
||
SegmentDuration: 2,
|
||
RequireReady: true,
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(requestBody))
|
||
rr := httptest.NewRecorder()
|
||
server.handleStart(rr, req)
|
||
if rr.Code != http.StatusInternalServerError {
|
||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
server.mu.RLock()
|
||
predecessor := server.sessions["public-session"]
|
||
_, replacementRegistered := server.sessions["public-session-legacy-replacement"]
|
||
server.mu.RUnlock()
|
||
if predecessor == nil {
|
||
t.Fatal("failed distinct replacement removed the active predecessor")
|
||
}
|
||
if replacementRegistered {
|
||
t.Fatal("failed distinct replacement was registered")
|
||
}
|
||
if got := server.activeJobs.Load(); got != 1 {
|
||
t.Fatalf("active jobs = %d, want predecessor only", got)
|
||
}
|
||
}
|
||
|
||
func signCard(t *testing.T, card playback.RecipeCard) string {
|
||
t.Helper()
|
||
tok, err := streamtoken.Sign(card.ToClaims(), testSecret, time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("sign card: %v", err)
|
||
}
|
||
return tok
|
||
}
|
||
|
||
func requestWithToken(sessionID, token string) *http.Request {
|
||
r := httptest.NewRequest(http.MethodGet, "/transcode/"+sessionID+"/master.m3u8", nil)
|
||
if token != "" {
|
||
r.Header.Set("X-Silo-Stream-Token", token)
|
||
}
|
||
return r
|
||
}
|
||
|
||
func transcodeCard(sessionID string) playback.RecipeCard {
|
||
return playback.NewRecipeCard(7, "profile-1", 42, "", playback.TranscodeOpts{
|
||
SessionID: sessionID,
|
||
InputPath: "/media/movie.mkv",
|
||
TargetCodecVideo: "h264",
|
||
SegmentDuration: 6,
|
||
})
|
||
}
|
||
|
||
// reconstructFromToken must refuse — without spawning ffmpeg — every request that
|
||
// does not carry a valid, matching transcode token. These guards run before any
|
||
// StartTranscode, so they are safe to assert without ffmpeg or a media file.
|
||
func TestReconstructFromToken_RejectsUnusableTokens(t *testing.T) {
|
||
const sid = "sess-123"
|
||
s := newTestServer(t)
|
||
|
||
t.Run("missing token header", func(t *testing.T) {
|
||
if got := s.reconstructFromToken(requestWithToken(sid, ""), sid, -1); got != nil {
|
||
t.Fatalf("expected nil for missing token, got %v", got)
|
||
}
|
||
})
|
||
|
||
t.Run("invalid signature", func(t *testing.T) {
|
||
bad, err := streamtoken.Sign(transcodeCard(sid).ToClaims(), "wrong-secret", time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("sign: %v", err)
|
||
}
|
||
if got := s.reconstructFromToken(requestWithToken(sid, bad), sid, -1); got != nil {
|
||
t.Fatalf("expected nil for bad signature, got %v", got)
|
||
}
|
||
})
|
||
|
||
t.Run("session id mismatch", func(t *testing.T) {
|
||
tok := signCard(t, transcodeCard("other-session"))
|
||
if got := s.reconstructFromToken(requestWithToken(sid, tok), sid, -1); got != nil {
|
||
t.Fatalf("expected nil for session id mismatch, got %v", got)
|
||
}
|
||
})
|
||
|
||
t.Run("non-transcode card", func(t *testing.T) {
|
||
tok := signCard(t, playback.NewDirectRecipeCard(sid, 7, "profile-1", 42))
|
||
if got := s.reconstructFromToken(requestWithToken(sid, tok), sid, -1); got != nil {
|
||
t.Fatalf("expected nil for direct-play card, got %v", got)
|
||
}
|
||
})
|
||
|
||
// The jellycompat node hop signs an identity-only transcode token (the recipe
|
||
// lives in the central compat store). Its card decodes as PlayTranscode for the
|
||
// right session id but with no encode parameters; with no recipe store wired the
|
||
// node must refuse it rather than spawn a malformed ffmpeg.
|
||
t.Run("recipe-less transcode token, no recipe store", func(t *testing.T) {
|
||
tok := signCard(t, playback.RecipeCard{
|
||
SessionID: sid,
|
||
UserID: 7,
|
||
PlayMethod: playback.PlayTranscode,
|
||
InputPath: "/media/movie.mkv",
|
||
})
|
||
if got := s.reconstructFromToken(requestWithToken(sid, tok), sid, 5); got != nil {
|
||
t.Fatalf("expected nil for recipe-less transcode token, got %v", got)
|
||
}
|
||
})
|
||
}
|
||
|
||
// stubRecipeStore is a recipeStore for the jellycompat node-restart fetch path.
|
||
type stubRecipeStore struct {
|
||
card *playback.RecipeCard
|
||
ok bool
|
||
hits int
|
||
deletes []string
|
||
delErr error
|
||
}
|
||
|
||
func (s *stubRecipeStore) Get(context.Context, string) (*playback.RecipeCard, bool) {
|
||
s.hits++
|
||
return s.card, s.ok
|
||
}
|
||
|
||
func (s *stubRecipeStore) Delete(_ context.Context, sessionID string) error {
|
||
s.deletes = append(s.deletes, sessionID)
|
||
return s.delErr
|
||
}
|
||
|
||
// When the forwarded token is recipe-less (jellycompat), the node consults the
|
||
// recipe store. A miss or an incomplete recipe must yield a clean nil (404) with
|
||
// no ffmpeg spawn — these assert the resolve guards without needing ffmpeg.
|
||
func TestReconstructFromToken_JellycompatRecipeFetch(t *testing.T) {
|
||
const sid = "compat-sess-1"
|
||
recipeLessToken := func(t *testing.T) string {
|
||
return signCard(t, playback.RecipeCard{
|
||
SessionID: sid,
|
||
UserID: 7,
|
||
PlayMethod: playback.PlayTranscode,
|
||
InputPath: "/media/movie.mkv",
|
||
})
|
||
}
|
||
|
||
t.Run("store miss -> nil", func(t *testing.T) {
|
||
s := newTestServer(t)
|
||
store := &stubRecipeStore{ok: false}
|
||
s.SetRecipeStore(store)
|
||
if got := s.reconstructFromToken(requestWithToken(sid, recipeLessToken(t)), sid, 5); got != nil {
|
||
t.Fatalf("expected nil on store miss, got %v", got)
|
||
}
|
||
if store.hits != 1 {
|
||
t.Fatalf("recipe store consulted %d times, want 1", store.hits)
|
||
}
|
||
})
|
||
|
||
t.Run("incomplete fetched recipe -> nil", func(t *testing.T) {
|
||
s := newTestServer(t)
|
||
// Right session id but missing encode params: must not spawn.
|
||
s.SetRecipeStore(&stubRecipeStore{ok: true, card: &playback.RecipeCard{SessionID: sid, PlayMethod: playback.PlayTranscode}})
|
||
if got := s.reconstructFromToken(requestWithToken(sid, recipeLessToken(t)), sid, 5); got != nil {
|
||
t.Fatalf("expected nil for incomplete fetched recipe, got %v", got)
|
||
}
|
||
})
|
||
|
||
t.Run("fetched recipe for wrong session -> nil", func(t *testing.T) {
|
||
s := newTestServer(t)
|
||
s.SetRecipeStore(&stubRecipeStore{ok: true, card: &playback.RecipeCard{
|
||
SessionID: "other", PlayMethod: playback.PlayTranscode, SegmentDuration: 6, TargetCodecVideo: "h264",
|
||
}})
|
||
if got := s.reconstructFromToken(requestWithToken(sid, recipeLessToken(t)), sid, 5); got != nil {
|
||
t.Fatalf("expected nil for wrong-session recipe, got %v", got)
|
||
}
|
||
})
|
||
}
|
||
|
||
// handleStop is a deliberate teardown, so it must drop the session's recipe to
|
||
// stop a buffered/retrying post-restart request from reconstructing a brand-new
|
||
// ffmpeg for an already-stopped session. A zero-value TranscodeSession needs no
|
||
// ffmpeg or media file to Close, so this asserts the wiring without a real spawn.
|
||
func TestHandleStop_DeletesRecipe(t *testing.T) {
|
||
const sid = "stop-sess-1"
|
||
s := newTestServer(t)
|
||
s.tracker = nodesessions.NewTracker(nil, "node-url", "node-name", "transcode")
|
||
store := &stubRecipeStore{}
|
||
s.SetRecipeStore(store)
|
||
|
||
s.sessions[sid] = &playback.TranscodeSession{}
|
||
s.activeJobs.Store(1)
|
||
|
||
r := httptest.NewRequest(http.MethodDelete, "/transcode/"+sid, nil)
|
||
rctx := chi.NewRouteContext()
|
||
rctx.URLParams.Add("session_id", sid)
|
||
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, rctx))
|
||
rec := httptest.NewRecorder()
|
||
s.handleStop(rec, r)
|
||
|
||
if rec.Code != http.StatusNoContent {
|
||
t.Fatalf("handleStop status = %d, want %d", rec.Code, http.StatusNoContent)
|
||
}
|
||
if len(store.deletes) != 1 || store.deletes[0] != sid {
|
||
t.Fatalf("recipe deletes = %v, want [%q]", store.deletes, sid)
|
||
}
|
||
if _, ok := s.sessions[sid]; ok {
|
||
t.Fatalf("session %q still registered after stop", sid)
|
||
}
|
||
}
|
||
|
||
// The idle reaper must close only jobs whose last access predates the TTL;
|
||
// registration counts as an access, so a just-started job (including one still
|
||
// waiting on its manifest in the RequireReady flow) is spared. Zero-value
|
||
// TranscodeSessions Close without ffmpeg, so this runs without a real spawn.
|
||
func TestReapIdleSessions_ClosesOnlyIdleJobs(t *testing.T) {
|
||
s := newTestServer(t)
|
||
s.tracker = nodesessions.NewTracker(nil, "node-url", "node-name", "transcode")
|
||
|
||
s.sessions["fresh-1"] = &playback.TranscodeSession{}
|
||
s.sessions["stale-1"] = &playback.TranscodeSession{}
|
||
s.lastAccess = map[string]time.Time{
|
||
"fresh-1": time.Now(),
|
||
"stale-1": time.Now().Add(-sessionIdleTTL - time.Minute),
|
||
}
|
||
s.activeJobs.Store(2)
|
||
|
||
s.reapIdleSessions(sessionIdleTTL)
|
||
|
||
s.mu.RLock()
|
||
_, freshAlive := s.sessions["fresh-1"]
|
||
_, staleAlive := s.sessions["stale-1"]
|
||
_, staleTracked := s.lastAccess["stale-1"]
|
||
s.mu.RUnlock()
|
||
if !freshAlive {
|
||
t.Fatal("recently accessed session was reaped")
|
||
}
|
||
if staleAlive {
|
||
t.Fatal("idle session survived the reaper")
|
||
}
|
||
if staleTracked {
|
||
t.Fatal("reaped session's idle clock was not dropped")
|
||
}
|
||
if got := s.activeJobs.Load(); got != 1 {
|
||
t.Fatalf("activeJobs = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
// A registered job with no recorded access (untracked registration) must not
|
||
// be closed; the sweep starts its idle clock instead of reaping a job that may
|
||
// be actively serving.
|
||
func TestReapIdleSessions_StartsClockForUntrackedJob(t *testing.T) {
|
||
s := newTestServer(t)
|
||
s.sessions["untracked-1"] = &playback.TranscodeSession{}
|
||
s.activeJobs.Store(1)
|
||
|
||
s.reapIdleSessions(sessionIdleTTL)
|
||
|
||
s.mu.RLock()
|
||
_, alive := s.sessions["untracked-1"]
|
||
last, tracked := s.lastAccess["untracked-1"]
|
||
s.mu.RUnlock()
|
||
if !alive {
|
||
t.Fatal("untracked session was reaped")
|
||
}
|
||
if !tracked || last.IsZero() {
|
||
t.Fatal("sweep did not start the untracked session's idle clock")
|
||
}
|
||
if got := s.activeJobs.Load(); got != 1 {
|
||
t.Fatalf("activeJobs = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
// touchSession must refresh a registered job's idle clock and ignore ids with
|
||
// no live session (a reconstruct records its own first access on register).
|
||
func TestTouchSession_RefreshesIdleClock(t *testing.T) {
|
||
s := newTestServer(t)
|
||
s.sessions["live-1"] = &playback.TranscodeSession{}
|
||
stale := time.Now().Add(-sessionIdleTTL - time.Minute)
|
||
s.lastAccess = map[string]time.Time{"live-1": stale}
|
||
|
||
s.touchSession("live-1")
|
||
s.touchSession("ghost-1")
|
||
|
||
s.mu.RLock()
|
||
defer s.mu.RUnlock()
|
||
if !s.lastAccess["live-1"].After(stale) {
|
||
t.Fatal("touch did not refresh the live session's idle clock")
|
||
}
|
||
if _, ok := s.lastAccess["ghost-1"]; ok {
|
||
t.Fatal("touch recorded access for an unregistered session")
|
||
}
|
||
}
|
||
|
||
// spawnReconstruct must NOT apply the fast seg×dur resume seek for copy-mode
|
||
// cards: copy-mode segments have variable durations, so seg×dur points at the
|
||
// wrong source time. The card's original start must stand. Asserting opts off a
|
||
// real spawn would need ffmpeg, so this checks the gating condition directly.
|
||
func TestCopyModeReconstruct_SkipsFastSeek(t *testing.T) {
|
||
const dur = 6
|
||
card := playback.RecipeCard{
|
||
SessionID: "copy-sess-1",
|
||
PlayMethod: playback.PlayTranscode,
|
||
TargetCodecVideo: "copy",
|
||
SegmentDuration: dur,
|
||
StartSegmentNumber: 0,
|
||
}
|
||
const requestedSegment = 10
|
||
applyFastSeek := requestedSegment > card.StartSegmentNumber && card.SegmentDuration > 0 &&
|
||
!strings.EqualFold(card.TargetCodecVideo, "copy")
|
||
if applyFastSeek {
|
||
t.Fatalf("copy-mode card must not apply the seg×dur fast seek")
|
||
}
|
||
|
||
// Same shape but ENCODED: the fast seek must apply.
|
||
card.TargetCodecVideo = "h264"
|
||
applyFastSeek = requestedSegment > card.StartSegmentNumber && card.SegmentDuration > 0 &&
|
||
!strings.EqualFold(card.TargetCodecVideo, "copy")
|
||
if !applyFastSeek {
|
||
t.Fatalf("encoded card must apply the seg×dur fast seek")
|
||
}
|
||
}
|
||
|
||
// A fresh /transcode/start must resolve this node's configured hw_device list
|
||
// through the shared GPU pool — the same path reconstruction uses — rather
|
||
// than bypassing it with an empty device.
|
||
func TestHandleStartUsesConfiguredHWDeviceList(t *testing.T) {
|
||
server := newTestServer(t)
|
||
ffmpegPath := filepath.Join(t.TempDir(), "looping-ffmpeg.sh")
|
||
if err := os.WriteFile(ffmpegPath, []byte("#!/bin/sh\nwhile :; do sleep 0.1; done\n"), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// This test reaches the spawn/track path, so it needs a (no-op) tracker.
|
||
server.tracker = nodesessions.NewTracker(nil, "http://node", "node", "transcode")
|
||
cfg := server.watcher.Config()
|
||
cfg.Playback.FFmpegPath = ffmpegPath
|
||
// Neither device exists, so resolution deterministically lands on the
|
||
// first entry; the point is that the configured list reaches the session.
|
||
cfg.Playback.HWDevice = "/dev/dri/renderD888,/dev/dri/renderD889"
|
||
|
||
requestBody, err := json.Marshal(TranscodeStartRequest{
|
||
SessionID: "hwdevice-start-1",
|
||
InputPath: "/media/movie.mkv",
|
||
TargetCodecVideo: "h264",
|
||
TargetCodecAudio: "aac",
|
||
SegmentDuration: 2,
|
||
HWAccel: "vaapi",
|
||
})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
req := httptest.NewRequest(http.MethodPost, "/transcode/start", bytes.NewReader(requestBody))
|
||
rr := httptest.NewRecorder()
|
||
server.handleStart(rr, req)
|
||
if rr.Code != http.StatusAccepted {
|
||
t.Fatalf("status = %d, body = %s", rr.Code, rr.Body.String())
|
||
}
|
||
|
||
server.mu.RLock()
|
||
session := server.sessions["hwdevice-start-1"]
|
||
server.mu.RUnlock()
|
||
if session == nil {
|
||
t.Fatal("session was not registered")
|
||
}
|
||
defer session.CloseProcess()
|
||
if got := session.Opts().HWDevice; got != "/dev/dri/renderD888" {
|
||
t.Fatalf("session HWDevice = %q, want one concrete device from the configured list", got)
|
||
}
|
||
}
|