diff --git a/internal/api/config.go b/internal/api/config.go index 9072e8d3..55dc1614 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -1,11 +1,15 @@ package api import ( + "errors" "io" + "maps" "net/http" "os" + "slices" "github.com/AlexxIT/go2rtc/internal/app" + pkgyaml "github.com/AlexxIT/go2rtc/pkg/yaml" "gopkg.in/yaml.v3" ) @@ -55,47 +59,100 @@ func configHandler(w http.ResponseWriter, r *http.Request) { } func mergeYAML(file1 string, yaml2 []byte) ([]byte, error) { - // Read the contents of the first YAML file data1, err := os.ReadFile(file1) if err != nil { return nil, err } - // Unmarshal the first YAML file into a map - var config1 map[string]any - if err = yaml.Unmarshal(data1, &config1); err != nil { + var patch map[string]any + if err = yaml.Unmarshal(yaml2, &patch); err != nil { return nil, err } - // Unmarshal the second YAML document into a map - var config2 map[string]any - if err = yaml.Unmarshal(yaml2, &config2); err != nil { + data1, err = mergeYAMLMap(data1, nil, patch) + if err != nil { return nil, err } - // Merge the two maps - config1 = merge(config1, config2) + // validate config after merge + if err = yaml.Unmarshal(data1, map[string]any{}); err != nil { + return nil, err + } - // Marshal the merged map into YAML - return yaml.Marshal(&config1) + return data1, nil } -func merge(dst, src map[string]any) map[string]any { - for k, v := range src { - if vv, ok := dst[k]; ok { - switch vv := vv.(type) { - case map[string]any: - v := v.(map[string]any) - dst[k] = merge(vv, v) - case []any: - v := v.([]any) - dst[k] = v - default: - dst[k] = v +// mergeYAMLMap recursively applies patch values onto config bytes. +func mergeYAMLMap(data []byte, path []string, patch map[string]any) ([]byte, error) { + for _, key := range slices.Sorted(maps.Keys(patch)) { + value := patch[key] + currPath := append(append([]string(nil), path...), key) + + if valueMap, ok := value.(map[string]any); ok { + isMap, exists, err := pathIsMapping(data, currPath) + if err != nil { + return nil, err } - } else { - dst[k] = v + + if exists && isMap { + data, err = mergeYAMLMap(data, currPath, valueMap) + } else { + data, err = pkgyaml.Patch(data, currPath, valueMap) + } + if err != nil { + return nil, err + } + continue + } + + var err error + data, err = pkgyaml.Patch(data, currPath, value) + if err != nil { + return nil, err } } - return dst + return data, nil +} + +// pathIsMapping reports whether path exists and ends with a mapping node. +func pathIsMapping(data []byte, path []string) (isMap, exists bool, err error) { + var root yaml.Node + if err = yaml.Unmarshal(data, &root); err != nil { + return false, false, err + } + + if len(root.Content) == 0 { + return false, false, nil + } + + if len(root.Content) != 1 || root.Content[0].Kind != yaml.MappingNode { + return false, false, errors.New("yaml: expected mapping document") + } + + node := root.Content[0] + for i, part := range path { + idx := -1 + for j := 0; j < len(node.Content); j += 2 { + if node.Content[j].Value == part { + idx = j + break + } + } + if idx < 0 { + return false, false, nil + } + + valueNode := node.Content[idx+1] + if i == len(path)-1 { + return valueNode.Kind == yaml.MappingNode, true, nil + } + + if valueNode.Kind != yaml.MappingNode { + return false, false, nil + } + + node = valueNode + } + + return false, false, nil } diff --git a/internal/api/config_test.go b/internal/api/config_test.go new file mode 100644 index 00000000..f5093df2 --- /dev/null +++ b/internal/api/config_test.go @@ -0,0 +1,283 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestMergeYAMLPreserveCommentedStreamList(t *testing.T) { + base := `streams: + yard: + - #http://1.1.1.1 + - #http://2.2.2.2 + - http://3.3.3.3 + - #http://4.4.4.4 +log: + level: trace +` + patch := `log: + api: debug +` + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + require.Contains(t, merged, "#http://1.1.1.1") + require.Contains(t, merged, "#http://2.2.2.2") + require.Contains(t, merged, "#http://4.4.4.4") + require.Contains(t, merged, "- http://3.3.3.3") + require.NotContains(t, merged, "- null") + require.Contains(t, merged, "api: debug") + + var cfg map[string]any + require.NoError(t, yaml.Unmarshal(out, &cfg)) +} + +func TestMergeYAMLPreserveUnchangedComments(t *testing.T) { + base := `api: + username: admin +streams: + yard: + - #http://1.1.1.1 + - http://3.3.3.3 +` + patch := `api: + password: secret +` + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + require.Contains(t, merged, "username: admin") + require.Contains(t, merged, "password: secret") + require.Contains(t, merged, "#http://1.1.1.1") + require.NotContains(t, merged, "- null") +} + +func TestMergeYAMLPreserveCommentsAndFormattingAcrossSections(t *testing.T) { + base := `# global config comment +api: # api section comment + username: admin # inline username comment +streams: + # stream comment + yard: + - #http://1.1.1.1 + - http://3.3.3.3 +log: + format: | + line1 + line2 +` + patch := `api: + password: "secret value" +ffmpeg: + bin: /usr/bin/ffmpeg +` + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + require.Contains(t, merged, "# global config comment") + require.Contains(t, merged, "# api section comment") + require.Contains(t, merged, "# inline username comment") + require.Contains(t, merged, "# stream comment") + require.Contains(t, merged, "#http://1.1.1.1") + require.Contains(t, merged, "password: secret value") + require.Contains(t, merged, "format: |") + require.NotContains(t, merged, "- null") + + assertOrder(t, merged, "api:", "streams:", "log:", "ffmpeg:") + + var cfg map[string]any + require.NoError(t, yaml.Unmarshal(out, &cfg)) + require.Equal(t, "admin", cfg["api"].(map[string]any)["username"]) + require.Equal(t, "secret value", cfg["api"].(map[string]any)["password"]) + require.Equal(t, "/usr/bin/ffmpeg", cfg["ffmpeg"].(map[string]any)["bin"]) +} + +func TestMergeYAMLPreserveQuotedValuesAndNestedStructure(t *testing.T) { + base := `api: + username: "admin user" + listen: ":1984" +webrtc: + candidates: + - "stun:stun.l.google.com:19302" +streams: + porch: + - "rtsp://cam.local/stream?token=a:b" + - #disabled source +` + patch := `webrtc: + ice_servers: + - urls: + - stun:stun.cloudflare.com:3478 +` + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + require.Contains(t, merged, `username: "admin user"`) + require.Contains(t, merged, `listen: ":1984"`) + require.Contains(t, merged, `"rtsp://cam.local/stream?token=a:b"`) + require.Contains(t, merged, "#disabled source") + require.Contains(t, merged, "ice_servers:") + require.NotContains(t, merged, "- null") + + var cfg map[string]any + require.NoError(t, yaml.Unmarshal(out, &cfg)) + require.Equal(t, "admin user", cfg["api"].(map[string]any)["username"]) + require.Equal(t, ":1984", cfg["api"].(map[string]any)["listen"]) + require.NotNil(t, cfg["streams"]) + require.NotNil(t, cfg["webrtc"].(map[string]any)["candidates"]) + require.NotNil(t, cfg["webrtc"].(map[string]any)["ice_servers"]) +} + +func TestMergeYAMLPatchLogKeepsCommentedYardEntriesInline(t *testing.T) { + base := `api: + listen: :1984 + read_only: false + static_dir: www +log: + level: trace +mcp: + enabled: true + http: true + sse: true +streams: + cam_main: + - https://example.local/stream.m3u8 + yard: + - #http://camera.local/disabled-source-a + - #ffmpeg:http://camera.local/disabled-source-b#video=h264 + - ffmpeg:yard#video=mjpeg + - #homekit://camera.local/disabled-source-c + - homekit://camera.local/enabled-source +` + patch := `log: + api: debug +` + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + require.Contains(t, merged, " level: trace") + require.Contains(t, merged, " api: debug") + require.Contains(t, merged, " - #http://camera.local/disabled-source-a") + require.Contains(t, merged, " - #ffmpeg:http://camera.local/disabled-source-b#video=h264") + require.Contains(t, merged, " - #homekit://camera.local/disabled-source-c") + require.NotContains(t, merged, "\n -\n") + + var cfg map[string]any + require.NoError(t, yaml.Unmarshal(out, &cfg)) + require.Equal(t, "debug", cfg["log"].(map[string]any)["api"]) +} + +func TestMergeYAMLPatchLogWithTrailingSpaces(t *testing.T) { + // trailing spaces on "- #comment" lines could confuse node parsing + base := "api:\n listen: :1984\nlog:\n level: trace\nstreams:\n yard:\n" + + " - #http://192.168.88.100/long/path/to/resource \n" + + " - #ffmpeg:http://192.168.88.100/path#video=h264 \n" + + " - ffmpeg:yard#video=mjpeg\n" + + patch := "log:\n api: debug\n" + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + require.Contains(t, merged, " - #http://192.168.88.100/long/path/to/resource") + require.Contains(t, merged, " - #ffmpeg:http://192.168.88.100/path#video=h264") + require.NotContains(t, merged, "\n -\n") +} + +func TestMergeYAMLPatchLogPreservesLongCommentedURLs(t *testing.T) { + base := "api:\n" + + " listen: :1984\n" + + " read_only: false\n" + + " static_dir: www\n" + + "log:\n" + + " level: trace\n" + + "mcp:\n" + + " enabled: true\n" + + " http: true\n" + + " sse: true\n" + + "streams:\n" + + " sf_i280_us101:\n" + + " - https://wzmedia.dot.ca.gov/D4/N280_at_JCT_101.stream/playlist.m3u8\n" + + " testsrc_h264:\n" + + " - exec:ffmpeg -hide_banner -re -f lavfi -i testsrc=size=320x240:rate=15 -c:v libx264 -preset ultrafast -tune zerolatency -profile:v baseline -pix_fmt yuv420p -crf 28 -f h264 -\n" + + " yard:\n" + + " - #http://192.168.88.100/c17d5873fa8f1ca5e0f94daa46e29343/live/files/high/index.m3u8\n" + + " - #ffmpeg:http://192.168.88.100/c17d5873fa8f1ca5e0f94daa46e29343/live/files/high/index.m3u8#audio=opus/16000#video=h264\n" + + " - ffmpeg:yard#video=mjpeg\n" + + " - #homekit://192.168.88.100:5001?client_id=00000000-0000-0000-0000-000000000001&client_private=0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001&device_id=00:00:00:00:00:01&device_public=0000000000000000000000000000000000000000000000000000000000000001\n" + + " - homekit://192.168.88.100:5001?client_id=00000000-0000-0000-0000-000000000002&client_private=0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002&device_id=00:00:00:00:00:02&device_public=0000000000000000000000000000000000000000000000000000000000000002\n" + + patch := "log:\n api: debug\n" + + path := filepath.Join(t.TempDir(), "go2rtc.yaml") + require.NoError(t, os.WriteFile(path, []byte(base), 0o644)) + + out, err := mergeYAML(path, []byte(patch)) + require.NoError(t, err) + + merged := string(out) + + // patch applied + require.Contains(t, merged, " api: debug") + require.Contains(t, merged, " level: trace") + + // commented entries must stay on same line as dash + require.Contains(t, merged, " - #http://192.168.88.100/") + require.Contains(t, merged, " - #ffmpeg:http://192.168.88.100/") + require.Contains(t, merged, " - #homekit://192.168.88.100:") + require.NotContains(t, merged, "\n -\n") + + // non-commented entries preserved + require.Contains(t, merged, " - ffmpeg:yard#video=mjpeg") + require.Contains(t, merged, " - homekit://192.168.88.100:5001?client_id=00000000-0000-0000-0000-000000000002") + + var cfg map[string]any + require.NoError(t, yaml.Unmarshal(out, &cfg)) + require.Equal(t, "debug", cfg["log"].(map[string]any)["api"]) +} + +func assertOrder(t *testing.T, s string, items ...string) { + t.Helper() + + last := -1 + for _, item := range items { + idx := strings.Index(s, item) + require.NotEqualf(t, -1, idx, "expected %q in output", item) + require.Greaterf(t, idx, last, "expected %q after previous sections", item) + last = idx + } +} diff --git a/pkg/yaml/yaml.go b/pkg/yaml/yaml.go index ad657225..595e826d 100644 --- a/pkg/yaml/yaml.go +++ b/pkg/yaml/yaml.go @@ -59,38 +59,41 @@ func patch(in []byte, path []string, value any) ([]byte, error) { n := len(path) - 1 - // parent node key/value - pKey, pVal := findNode(nodes, path[:n]) - if pKey == nil { - // no parent node - return addToEnd(in, path, value) - } - var paste []byte - if value != nil { - // nil value means delete key var err error - v := map[string]any{path[n]: value} - if paste, err = Encode(v, 2); err != nil { + if paste, err = Encode(map[string]any{path[n]: value}, 2); err != nil { return nil, err } } + // top-level key + if n == 0 { + for i := 0; i < len(nodes); i += 2 { + if nodes[i].Value == path[0] { + i0, i1 := nodeBounds(in, nodes[i]) + return join(in[:i0], paste, in[i1:]), nil + } + } + return join(in, paste), nil + } + + // nested key + pKey, pVal := findNode(nodes, path[:n]) + if pKey == nil { + return addToEnd(in, path, value) + } + iKey, _ := findNode(pVal.Content, path[n:]) if iKey != nil { - // key item not nil (replace value) paste = addIndent(paste, iKey.Column-1) - i0, i1 := nodeBounds(in, iKey) return join(in[:i0], paste, in[i1:]), nil } if pVal.Content != nil { - // parent value not nil (use first child indent) paste = addIndent(paste, pVal.Column-1) } else { - // parent value is nil (use parent indent + 2) paste = addIndent(paste, pKey.Column+1) } @@ -138,13 +141,20 @@ func nodeBounds(in []byte, node *yaml.Node) (offset0, offset1 int) { } func addToEnd(in []byte, path []string, value any) ([]byte, error) { - if len(path) != 2 || value == nil { + if value == nil { return nil, errors.New("yaml: path not exist") } - v := map[string]map[string]any{ - path[0]: {path[1]: value}, + var v any + switch len(path) { + case 1: + v = map[string]any{path[0]: value} + case 2: + v = map[string]map[string]any{path[0]: {path[1]: value}} + default: + return nil, errors.New("yaml: path not exist") } + paste, err := Encode(v, 2) if err != nil { return nil, err diff --git a/pkg/yaml/yaml_test.go b/pkg/yaml/yaml_test.go index 264546af..f9f7a723 100644 --- a/pkg/yaml/yaml_test.go +++ b/pkg/yaml/yaml_test.go @@ -98,6 +98,20 @@ func TestPatch(t *testing.T) { value: []string{"val1"}, expect: "streams:\n camera1: url1\nhomekit:\n camera1:\n name: dummy\n pairings:\n - val1\n", }, + { + name: "top-level replace", + src: "api:\n listen: :1984\nlog:\n level: trace\n", + path: []string{"log"}, + value: map[string]any{"level": "debug"}, + expect: "api:\n listen: :1984\nlog:\n level: debug\n", + }, + { + name: "top-level add", + src: "api:\n listen: :1984\n", + path: []string{"ffmpeg"}, + value: map[string]any{"bin": "/usr/bin/ffmpeg"}, + expect: "api:\n listen: :1984\nffmpeg:\n bin: /usr/bin/ffmpeg\n", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) {