Add credential extraction registry for generate

Protocols like Xiaomi need credentials (tokens) in a separate top-level
YAML section, not in the stream URL itself. Introduce a registry pattern
mirroring streams.HandleFunc / tester.RegisterSource:

- pkg/generate/registry.go: ExtractFunc + RegisterExtract
- Extractors clean the URL (strip ?token=...) and return section/key/value
- writeCredentials emits sorted sections between go2rtc: and cameras:
- upsertCredentials in addToConfig merges into existing sections:
  * replaces value if key exists (token refresh)
  * inserts in sorted order if new
  * creates new top-level section before cameras: if missing

Xiaomi registers its extractor from internal/xiaomi/xiaomi.go. Adding
Tapo/Ring/Roborock later is one line + a small function in their
internal/*/ module -- zero changes in pkg/generate/.
This commit is contained in:
eduard256
2026-04-18 08:36:48 +00:00
parent 8294736bcb
commit 12780d7803
5 changed files with 271 additions and 4 deletions
+154
View File
@@ -3,6 +3,7 @@ package generate
import (
"fmt"
"regexp"
"sort"
"strings"
)
@@ -64,6 +65,9 @@ func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, er
}
result = append(result, rest[split:]...)
// upsert credential sections (xiaomi, tapo, ...) before cameras:
result, added = upsertCredentials(result, info.Credentials, added)
config := strings.Join(result, "\n")
addedLines := make([]int, 0, len(added))
@@ -156,6 +160,156 @@ func findStreamInsertPoint(lines []string) int {
return -1
}
// upsertCredentials merges creds into existing top-level sections. For each
// section: if a matching line ` "<key>":` exists -- replace its value; else
// insert in sorted order. If the section itself doesn't exist -- create a new
// top-level block just before `cameras:`.
func upsertCredentials(lines []string, creds map[string]map[string]string, added map[int]bool) ([]string, map[int]bool) {
if len(creds) == 0 {
return lines, added
}
sections := make([]string, 0, len(creds))
for s := range creds {
sections = append(sections, s)
}
sort.Strings(sections)
for _, section := range sections {
lines, added = upsertSection(lines, section, creds[section], added)
}
return lines, added
}
// upsertSection updates or appends a single top-level section.
var reCredKey = regexp.MustCompile(`^\s{2}"([^"]+)":`)
func upsertSection(lines []string, section string, kv map[string]string, added map[int]bool) ([]string, map[int]bool) {
reHeader := regexp.MustCompile(`^` + regexp.QuoteMeta(section) + `:\s*$`)
headerIdx := -1
for i, line := range lines {
if reHeader.MatchString(line) {
headerIdx = i
break
}
}
if headerIdx == -1 {
return insertNewSection(lines, section, kv, added)
}
// section exists -- find last content line of the section (skip trailing blanks)
end := len(lines)
for i := headerIdx + 1; i < len(lines); i++ {
if strings.TrimSpace(lines[i]) == "" || reTopLevel.MatchString(lines[i]) {
end = i
break
}
}
keys := make([]string, 0, len(kv))
for k := range kv {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
newLine := fmt.Sprintf(" %q: %s", k, kv[k])
// try replace -- no length change, just mark modified line as added
replaced := false
for i := headerIdx + 1; i < end; i++ {
if m := reCredKey.FindStringSubmatch(lines[i]); m != nil && m[1] == k {
if lines[i] != newLine {
lines[i] = newLine
added[i] = true
}
replaced = true
break
}
}
if replaced {
continue
}
// insert in sorted order within section
insertAt := headerIdx + 1
for i := headerIdx + 1; i < end; i++ {
if m := reCredKey.FindStringSubmatch(lines[i]); m != nil {
if m[1] < k {
insertAt = i + 1
} else {
break
}
} else {
insertAt = i + 1
}
}
lines = append(lines[:insertAt], append([]string{newLine}, lines[insertAt:]...)...)
added = shiftAdded(added, insertAt)
added[insertAt] = true
end++
}
return lines, added
}
func insertNewSection(lines []string, section string, kv map[string]string, added map[int]bool) ([]string, map[int]bool) {
camIdx := -1
for i, line := range lines {
if reCamerasHeader.MatchString(line) {
camIdx = i
break
}
}
if camIdx == -1 {
camIdx = len(lines)
}
// insert point: right before the blank line that precedes cameras:
// keep one blank line between blocks
insertAt := camIdx
for insertAt > 0 && strings.TrimSpace(lines[insertAt-1]) == "" {
insertAt--
}
block := []string{section + ":"}
keys := make([]string, 0, len(kv))
for k := range kv {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
block = append(block, fmt.Sprintf(" %q: %s", k, kv[k]))
}
block = append(block, "")
lines = append(lines[:insertAt], append(block, lines[insertAt:]...)...)
added = shiftAdded(added, insertAt)
for i := range block {
added[insertAt+i] = true
}
return lines, added
}
// shiftAdded moves all marks at index >= from by +1. Also used with from=len(lines)
// as a no-op shift (just return same map).
func shiftAdded(added map[int]bool, from int) map[int]bool {
out := make(map[int]bool, len(added))
for i := range added {
if i >= from {
out[i+1] = true
} else {
out[i] = true
}
}
return out
}
func findCameraInsertPoint(lines []string) int {
in := false
last := -1