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
+24
View File
@@ -15,6 +15,7 @@ import (
"github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto" "github.com/AlexxIT/go2rtc/pkg/xiaomi/crypto"
"github.com/eduard256/strix/internal/api" "github.com/eduard256/strix/internal/api"
"github.com/eduard256/strix/internal/app" "github.com/eduard256/strix/internal/app"
"github.com/eduard256/strix/pkg/generate"
"github.com/eduard256/strix/pkg/tester" "github.com/eduard256/strix/pkg/tester"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@@ -53,6 +54,29 @@ func Init() {
}) })
api.HandleFunc("api/xiaomi", apiXiaomi) api.HandleFunc("api/xiaomi", apiXiaomi)
generate.RegisterExtract("xiaomi", extractForConfig)
}
// extractForConfig strips ?token=... from xiaomi:// URL and returns
// userID + token for a top-level `xiaomi:` yaml section.
// ex. xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X&token=V1:...
// -> xiaomi://4161148305:cn@10.0.20.229?did=450924912&model=X, "xiaomi", "4161148305", "V1:..."
func extractForConfig(rawURL string) (cleaned, section, key, value string) {
u, err := url.Parse(rawURL)
if err != nil || u.User == nil {
return rawURL, "", "", ""
}
q := u.Query()
token := q.Get("token")
if token == "" {
return rawURL, "", "", ""
}
q.Del("token")
u.RawQuery = q.Encode()
return u.String(), "xiaomi", u.User.Username(), token
} }
var log zerolog.Logger var log zerolog.Logger
+36 -4
View File
@@ -54,10 +54,16 @@ func buildInfo(req *Request) *cameraInfo {
streamBase = sanitized streamBase = sanitized
} }
mainSource, mainSection, mainKey, mainValue := runExtract(req.MainStream)
info := &cameraInfo{ info := &cameraInfo{
CameraName: base, CameraName: base,
MainStreamName: streamBase + "_main", MainStreamName: streamBase + "_main",
MainSource: req.MainStream, MainSource: mainSource,
}
if mainSection != "" {
info.addCredential(mainSection, mainKey, mainValue)
} }
if req.Name != "" { if req.Name != "" {
@@ -70,7 +76,11 @@ func buildInfo(req *Request) *cameraInfo {
info.MainStreamName = req.Go2RTC.MainStreamName info.MainStreamName = req.Go2RTC.MainStreamName
} }
if req.Go2RTC.MainStreamSource != "" { if req.Go2RTC.MainStreamSource != "" {
info.MainSource = req.Go2RTC.MainStreamSource src, section, key, value := runExtract(req.Go2RTC.MainStreamSource)
info.MainSource = src
if section != "" {
info.addCredential(section, key, value)
}
} }
} }
@@ -95,7 +105,12 @@ func buildInfo(req *Request) *cameraInfo {
if req.Name != "" { if req.Name != "" {
subName = req.Name + "_sub" subName = req.Name + "_sub"
} }
subSource := req.SubStream
subSource, subSection, subKey, subValue := runExtract(req.SubStream)
if subSection != "" {
info.addCredential(subSection, subKey, subValue)
}
subPath := "rtsp://127.0.0.1:8554/" + subName subPath := "rtsp://127.0.0.1:8554/" + subName
if needMP4[subScheme] { if needMP4[subScheme] {
subPath += "?mp4" subPath += "?mp4"
@@ -107,7 +122,11 @@ func buildInfo(req *Request) *cameraInfo {
subName = req.Go2RTC.SubStreamName subName = req.Go2RTC.SubStreamName
} }
if req.Go2RTC.SubStreamSource != "" { if req.Go2RTC.SubStreamSource != "" {
subSource = req.Go2RTC.SubStreamSource src, section, key, value := runExtract(req.Go2RTC.SubStreamSource)
subSource = src
if section != "" {
info.addCredential(section, key, value)
}
} }
} }
if req.Frigate != nil { if req.Frigate != nil {
@@ -137,6 +156,8 @@ func newConfig(info *cameraInfo, req *Request) string {
b.WriteString("go2rtc:\n streams:\n") b.WriteString("go2rtc:\n streams:\n")
writeStreamLines(&b, info) writeStreamLines(&b, info)
writeCredentials(&b, info.Credentials)
b.WriteString("cameras:\n") b.WriteString("cameras:\n")
writeCameraBlock(&b, info, req) writeCameraBlock(&b, info, req)
@@ -156,6 +177,17 @@ type cameraInfo struct {
SubSource string SubSource string
SubPath string SubPath string
SubInputArgs string SubInputArgs string
Credentials map[string]map[string]string // section -> key -> value
}
func (c *cameraInfo) addCredential(section, key, value string) {
if c.Credentials == nil {
c.Credentials = map[string]map[string]string{}
}
if c.Credentials[section] == nil {
c.Credentials[section] = map[string]string{}
}
c.Credentials[section][key] = value
} }
func urlScheme(rawURL string) string { func urlScheme(rawURL string) string {
+154
View File
@@ -3,6 +3,7 @@ package generate
import ( import (
"fmt" "fmt"
"regexp" "regexp"
"sort"
"strings" "strings"
) )
@@ -64,6 +65,9 @@ func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, er
} }
result = append(result, rest[split:]...) result = append(result, rest[split:]...)
// upsert credential sections (xiaomi, tapo, ...) before cameras:
result, added = upsertCredentials(result, info.Credentials, added)
config := strings.Join(result, "\n") config := strings.Join(result, "\n")
addedLines := make([]int, 0, len(added)) addedLines := make([]int, 0, len(added))
@@ -156,6 +160,156 @@ func findStreamInsertPoint(lines []string) int {
return -1 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 { func findCameraInsertPoint(lines []string) int {
in := false in := false
last := -1 last := -1
+24
View File
@@ -0,0 +1,24 @@
package generate
import "strings"
// ExtractFunc cleans rawURL (ex. strips ?token=...) and returns a top-level
// YAML section name + key/value to upsert into the config.
// Returns empty section if the URL has nothing to extract -- cleaned URL
// is still used as-is.
type ExtractFunc func(rawURL string) (cleaned, section, key, value string)
var extractors = map[string]ExtractFunc{}
func RegisterExtract(scheme string, fn ExtractFunc) {
extractors[scheme] = fn
}
func runExtract(rawURL string) (cleaned, section, key, value string) {
if i := strings.IndexByte(rawURL, ':'); i > 0 {
if fn := extractors[rawURL[:i]]; fn != nil {
return fn(rawURL)
}
}
return rawURL, "", "", ""
}
+33
View File
@@ -2,6 +2,7 @@ package generate
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
) )
@@ -17,6 +18,38 @@ func writeStreamLines(b *strings.Builder, info *cameraInfo) {
b.WriteByte('\n') b.WriteByte('\n')
} }
// writeCredentials writes top-level credential sections (xiaomi, tapo, ring, ...)
// populated by registered ExtractFunc handlers. Sorted by section, then by key.
// ex.
// xiaomi:
// "4161148305": V1:9d2w...
func writeCredentials(b *strings.Builder, creds map[string]map[string]string) {
if len(creds) == 0 {
return
}
sections := make([]string, 0, len(creds))
for s := range creds {
sections = append(sections, s)
}
sort.Strings(sections)
for _, section := range sections {
fmt.Fprintf(b, "%s:\n", section)
keys := make([]string, 0, len(creds[section]))
for k := range creds[section] {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(b, " %q: %s\n", k, creds[section][k])
}
b.WriteByte('\n')
}
}
func writeCameraBlock(b *strings.Builder, info *cameraInfo, req *Request) { func writeCameraBlock(b *strings.Builder, info *cameraInfo, req *Request) {
fmt.Fprintf(b, " %s:\n", info.CameraName) fmt.Fprintf(b, " %s:\n", info.CameraName)