fix(capture): accept a bare pipeline string in video pipelines entries (#701)

* fix(capture): accept a bare pipeline string in video pipelines entries

NEKO_CAPTURE_VIDEO_PIPELINES expects every entry to be a full pipeline config
object, but the singular capture.video.pipeline option trains people to write
the whole pipeline as a plain string, so a dict like {"high": "ximagesrc ...
! appsink"} failed to unmarshal, the error got reduced to a warn and neko fell
back to the default vp8 pipeline silently, also overriding a directly set
video codec

the json string hook decodes the env value into a generic map, so the shorthand
is restored in the mapstructure chain: a string value for an entry now becomes
VideoConfig{GstPipeline: ...}, the same shape the single pipeline option
produces, object entries are untouched

the new config test drives the production env flow (Init + Set) and fails on
master with the same two warnings the reporter saw

Fixes #689

* feat(capture): add shorthand support for pipeline entries as plain strings.

---------

Co-authored-by: Miroslav Šedivý <sedivy.miro@gmail.com>
This commit is contained in:
Abdellatif Anaflous
2026-09-10 12:45:30 +01:00
committed by GitHub
parent 371f5c2c7b
commit 73b9d71b0d
3 changed files with 87 additions and 1 deletions
+17 -1
View File
@@ -2,8 +2,10 @@ package config
import (
"os"
"reflect"
"strings"
"github.com/go-viper/mapstructure/v2"
"github.com/pion/webrtc/v4"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
@@ -322,6 +324,17 @@ func (Capture) InitV2(cmd *cobra.Command) error {
return nil
}
// videoPipelineShorthandHook accepts a bare pipeline string in place of a full
// video pipeline config, mirroring the capture.video.pipeline shorthand.
func videoPipelineShorthandHook() mapstructure.DecodeHookFunc {
return func(from reflect.Type, to reflect.Type, data any) (any, error) {
if from.Kind() == reflect.String && to == reflect.TypeOf(types.VideoConfig{}) {
return types.VideoConfig{GstPipeline: data.(string)}, nil
}
return data, nil
}
}
func (s *Capture) Set() {
var ok bool
@@ -342,7 +355,10 @@ func (s *Capture) Set() {
s.VideoIDs = viper.GetStringSlice("capture.video.ids")
if err := viper.UnmarshalKey("capture.video.pipelines", &s.VideoPipelines, viper.DecodeHook(
utils.JsonStringAutoDecode(s.VideoPipelines),
mapstructure.ComposeDecodeHookFunc(
utils.JsonStringAutoDecode(s.VideoPipelines),
videoPipelineShorthandHook(),
),
)); err != nil {
log.Warn().Err(err).Msgf("unable to parse video pipelines")
}
+58
View File
@@ -0,0 +1,58 @@
package config
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// loadCaptureFromEnv mirrors the production config flow: env prefix setup,
// flag registration via Init, then value population via Set.
func loadCaptureFromEnv(t *testing.T, pipelines string) *Capture {
t.Helper()
viper.Reset()
viper.SetEnvPrefix("NEKO")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
t.Setenv("NEKO_CAPTURE_VIDEO_PIPELINES", pipelines)
c := &Capture{}
cmd := &cobra.Command{}
if err := c.Init(cmd); err != nil {
t.Fatal(err)
}
c.Set()
return c
}
func TestVideoPipelinesEnv(t *testing.T) {
const pipeline = "ximagesrc display-name={display} show-pointer=false ! appsink name=appsink"
t.Run("object entries with gst_pipeline keep working", func(t *testing.T) {
c := loadCaptureFromEnv(t, `{"high":{"gst_pipeline":"`+pipeline+`"}}`)
cfg, ok := c.VideoPipelines["high"]
if !ok {
t.Fatalf("pipeline entry missing, got %v", c.VideoPipelines)
}
if cfg.GstPipeline != pipeline {
t.Errorf("expected pipeline %q, got %q", pipeline, cfg.GstPipeline)
}
})
t.Run("bare pipeline string entries are accepted", func(t *testing.T) {
c := loadCaptureFromEnv(t, `{"high":"`+pipeline+`"}`)
cfg, ok := c.VideoPipelines["high"]
if !ok {
t.Fatalf("bare pipeline string entry was dropped and neko fell back to the default vp8 pipeline, got %v", c.VideoPipelines)
}
if cfg.GstPipeline != pipeline {
t.Errorf("expected pipeline %q, got %q", pipeline, cfg.GstPipeline)
}
})
}