Rewrite Strix from scratch as single binary

Complete architecture rewrite following go2rtc patterns:
- pkg/ for pure logic (camdb, tester, probe, generate)
- internal/ for application glue with Init() modules
- Single HTTP server on :4567 with all endpoints
- zerolog with password masking and memory ring buffer
- Environment-based config only (no YAML files)

API endpoints: /api/search, /api/streams, /api/test,
/api/probe, /api/generate, /api/health, /api/log

Dependencies: go2rtc v1.9.14, go-sqlite3, miekg/dns, zerolog
This commit is contained in:
eduard256
2026-03-25 10:38:46 +00:00
parent 3b29188924
commit 27117900eb
3742 changed files with 2801 additions and 283718 deletions
+168
View File
@@ -0,0 +1,168 @@
package generate
import (
"fmt"
"net/url"
"regexp"
"strings"
)
var needMP4 = map[string]bool{"bubble": true}
var reIPv4 = regexp.MustCompile(`\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}`)
func Generate(req *Request) (*Response, error) {
if req.MainStream == "" {
return nil, fmt.Errorf("generate: mainStream required")
}
info := buildInfo(req)
if len(req.Objects) > 0 && (req.Detect == nil || !req.Detect.Enabled) {
if req.Detect == nil {
req.Detect = &DetectConfig{Enabled: true}
} else {
req.Detect.Enabled = true
}
}
if strings.TrimSpace(req.ExistingConfig) == "" {
config := newConfig(info, req)
return &Response{Config: config, Diff: fullDiff(config)}, nil
}
return addToConfig(req.ExistingConfig, info, req)
}
func buildInfo(req *Request) *cameraInfo {
mainScheme := urlScheme(req.MainStream)
ip := extractIP(req.MainStream)
sanitized := strings.NewReplacer(".", "_", ":", "_").Replace(ip)
base := "camera"
streamBase := "stream"
if ip != "" {
base = "camera_" + sanitized
streamBase = sanitized
}
info := &cameraInfo{
CameraName: base,
MainStreamName: streamBase + "_main",
MainSource: req.MainStream,
}
if req.Name != "" {
info.CameraName = req.Name
info.MainStreamName = req.Name + "_main"
}
if req.Go2RTC != nil {
if req.Go2RTC.MainStreamName != "" {
info.MainStreamName = req.Go2RTC.MainStreamName
}
if req.Go2RTC.MainStreamSource != "" {
info.MainSource = req.Go2RTC.MainStreamSource
}
}
info.MainPath = "rtsp://127.0.0.1:8554/" + info.MainStreamName
if needMP4[mainScheme] {
info.MainPath += "?mp4"
}
info.MainInputArgs = "preset-rtsp-restream"
if req.Frigate != nil {
if req.Frigate.MainStreamPath != "" {
info.MainPath = req.Frigate.MainStreamPath
}
if req.Frigate.MainStreamInputArgs != "" {
info.MainInputArgs = req.Frigate.MainStreamInputArgs
}
}
if req.SubStream != "" {
subScheme := urlScheme(req.SubStream)
subName := streamBase + "_sub"
if req.Name != "" {
subName = req.Name + "_sub"
}
subSource := req.SubStream
subPath := "rtsp://127.0.0.1:8554/" + subName
if needMP4[subScheme] {
subPath += "?mp4"
}
subInputArgs := "preset-rtsp-restream"
if req.Go2RTC != nil {
if req.Go2RTC.SubStreamName != "" {
subName = req.Go2RTC.SubStreamName
}
if req.Go2RTC.SubStreamSource != "" {
subSource = req.Go2RTC.SubStreamSource
}
}
if req.Frigate != nil {
if req.Frigate.SubStreamPath != "" {
subPath = req.Frigate.SubStreamPath
}
if req.Frigate.SubStreamInputArgs != "" {
subInputArgs = req.Frigate.SubStreamInputArgs
}
}
info.SubStreamName = subName
info.SubSource = subSource
info.SubPath = subPath
info.SubInputArgs = subInputArgs
}
return info
}
func newConfig(info *cameraInfo, req *Request) string {
var b strings.Builder
b.WriteString("mqtt:\n enabled: false\n\n")
b.WriteString("record:\n enabled: true\n retain:\n days: 7\n mode: motion\n\n")
b.WriteString("go2rtc:\n streams:\n")
writeStreamLines(&b, info)
b.WriteString("cameras:\n")
writeCameraBlock(&b, info, req)
b.WriteString("version: 0.18-0\n")
return b.String()
}
// internals
type cameraInfo struct {
CameraName string
MainStreamName string
MainSource string
MainPath string
MainInputArgs string
SubStreamName string
SubSource string
SubPath string
SubInputArgs string
}
func urlScheme(rawURL string) string {
if i := strings.IndexByte(rawURL, ':'); i > 0 {
return rawURL[:i]
}
return ""
}
func extractIP(rawURL string) string {
if u, err := url.Parse(rawURL); err == nil && u.Hostname() != "" {
return u.Hostname()
}
if m := reIPv4.FindString(rawURL); m != "" {
return m
}
return ""
}
+36
View File
@@ -0,0 +1,36 @@
package generate
import "strings"
func fullDiff(config string) []DiffLine {
lines := strings.Split(config, "\n")
diff := make([]DiffLine, len(lines))
for i, line := range lines {
diff[i] = DiffLine{Line: i + 1, Text: line, Type: "added"}
}
return diff
}
func diffWithContext(lines []string, added map[int]bool, ctx int) []DiffLine {
visible := make(map[int]bool)
for idx := range added {
for c := -ctx; c <= ctx; c++ {
if j := idx + c; j >= 0 && j < len(lines) {
visible[j] = true
}
}
}
var diff []DiffLine
for i, line := range lines {
if !visible[i] {
continue
}
t := "context"
if added[i] {
t = "added"
}
diff = append(diff, DiffLine{Line: i + 1, Text: line, Type: t})
}
return diff
}
+190
View File
@@ -0,0 +1,190 @@
package generate
import (
"fmt"
"regexp"
"strings"
)
var (
reCamerasHeader = regexp.MustCompile(`^cameras:`)
reTopLevel = regexp.MustCompile(`^[a-z]`)
reCameraName = regexp.MustCompile(`^\s{2}(\w[\w-]*):`)
reStreamsHeader = regexp.MustCompile(`^\s{2}streams:`)
reStreamName = regexp.MustCompile(`^\s{4}'?(\w[\w-]*)'?:`)
reStreamContent = regexp.MustCompile(`^\s{4,}`)
reNextSection = regexp.MustCompile(`^[a-z#]`)
reCameraBody = regexp.MustCompile(`^\s{2,}\S`)
reVersion = regexp.MustCompile(`^version:`)
)
func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, error) {
lines := strings.Split(existing, "\n")
existingCams := findNames(lines, reCamerasHeader, reCameraName)
existingStreams := findNames(lines, reStreamsHeader, reStreamName)
info = dedup(info, existingCams, existingStreams)
streamIdx := findStreamInsertPoint(lines)
cameraIdx := findCameraInsertPoint(lines)
if streamIdx == -1 || cameraIdx == -1 {
return nil, fmt.Errorf("generate: can't find go2rtc streams or cameras section")
}
var sb strings.Builder
writeStreamLines(&sb, info)
streamLines := strings.Split(strings.TrimRight(sb.String(), "\n"), "\n")
sb.Reset()
writeCameraBlock(&sb, info, req)
cameraLines := strings.Split(strings.TrimRight(sb.String(), "\n"), "\n")
added := make(map[int]bool)
result := make([]string, 0, len(lines)+len(streamLines)+len(cameraLines))
result = append(result, lines[:streamIdx]...)
mark := len(result)
result = append(result, streamLines...)
for i := range streamLines {
added[mark+i] = true
}
shift := len(streamLines)
adjCameraIdx := cameraIdx + shift
rest := lines[streamIdx:]
split := adjCameraIdx - len(result)
result = append(result, rest[:split]...)
mark = len(result)
result = append(result, cameraLines...)
for i := range cameraLines {
added[mark+i] = true
}
result = append(result, rest[split:]...)
config := strings.Join(result, "\n")
diff := diffWithContext(result, added, 3)
return &Response{Config: config, Diff: diff}, nil
}
func dedup(info *cameraInfo, cams, streams map[string]bool) *cameraInfo {
out := *info
suffix := 0
base := out.CameraName
for cams[out.CameraName] {
suffix++
out.CameraName = fmt.Sprintf("%s_%d", base, suffix)
}
base = out.MainStreamName
for streams[out.MainStreamName] {
suffix++
out.MainStreamName = fmt.Sprintf("%s_%d", base, suffix)
}
if out.SubStreamName != "" {
base = out.SubStreamName
for streams[out.SubStreamName] {
suffix++
out.SubStreamName = fmt.Sprintf("%s_%d", base, suffix)
}
}
return &out
}
func findNames(lines []string, header, nameRe *regexp.Regexp) map[string]bool {
names := make(map[string]bool)
in := false
for _, line := range lines {
if header.MatchString(line) {
in = true
continue
}
if in && reTopLevel.MatchString(line) {
break
}
if in {
if m := nameRe.FindStringSubmatch(line); m != nil {
names[m[1]] = true
}
}
}
return names
}
func findStreamInsertPoint(lines []string) int {
in := false
last := -1
headerIdx := -1
for i, line := range lines {
if reStreamsHeader.MatchString(line) {
in = true
headerIdx = i
continue
}
if in {
if reStreamContent.MatchString(line) {
last = i
} else if reNextSection.MatchString(line) {
if last >= 0 && last+1 < len(lines) && strings.TrimSpace(lines[last+1]) == "" {
return last + 2
}
if last >= 0 {
return last + 1
}
return headerIdx + 1
}
}
}
if last >= 0 {
return last + 1
}
if headerIdx >= 0 {
return headerIdx + 1
}
return -1
}
func findCameraInsertPoint(lines []string) int {
in := false
last := -1
headerIdx := -1
for i, line := range lines {
if reCamerasHeader.MatchString(line) {
in = true
headerIdx = i
continue
}
if in {
if reCameraBody.MatchString(line) {
last = i
} else if reTopLevel.MatchString(line) && !reCamerasHeader.MatchString(line) {
if last < 0 {
return headerIdx + 1
}
idx := last + 1
for idx < len(lines) && strings.TrimSpace(lines[idx]) == "" {
idx++
}
return idx
} else if reVersion.MatchString(line) {
if last < 0 {
return headerIdx + 1
}
idx := i
for idx > 0 && strings.TrimSpace(lines[idx-1]) == "" {
idx--
}
return idx
}
}
}
if headerIdx >= 0 {
return headerIdx + 1
}
return len(lines)
}
+117
View File
@@ -0,0 +1,117 @@
package generate
type Request struct {
MainStream string `json:"mainStream"`
SubStream string `json:"subStream,omitempty"`
Name string `json:"name,omitempty"`
ExistingConfig string `json:"existingConfig,omitempty"`
Go2RTC *Go2RTCOverride `json:"go2rtc,omitempty"`
Frigate *FrigateOverride `json:"frigate,omitempty"`
Objects []string `json:"objects,omitempty"`
Record *RecordConfig `json:"record,omitempty"`
Detect *DetectConfig `json:"detect,omitempty"`
Snapshots *BoolConfig `json:"snapshots,omitempty"`
Motion *MotionConfig `json:"motion,omitempty"`
FFmpeg *FFmpegConfig `json:"ffmpeg,omitempty"`
Live *LiveConfig `json:"live,omitempty"`
Audio *AudioConfig `json:"audio,omitempty"`
Birdseye *BirdseyeConfig `json:"birdseye,omitempty"`
ONVIF *ONVIFConfig `json:"onvif,omitempty"`
PTZ *PTZConfig `json:"ptz,omitempty"`
Notifications *BoolConfig `json:"notifications,omitempty"`
UI *UIConfig `json:"ui,omitempty"`
}
type Go2RTCOverride struct {
MainStreamName string `json:"mainStreamName,omitempty"`
SubStreamName string `json:"subStreamName,omitempty"`
MainStreamSource string `json:"mainStreamSource,omitempty"`
SubStreamSource string `json:"subStreamSource,omitempty"`
}
type FrigateOverride struct {
MainStreamPath string `json:"mainStreamPath,omitempty"`
SubStreamPath string `json:"subStreamPath,omitempty"`
MainStreamInputArgs string `json:"mainStreamInputArgs,omitempty"`
SubStreamInputArgs string `json:"subStreamInputArgs,omitempty"`
}
type RecordConfig struct {
Enabled bool `json:"enabled"`
RetainDays float64 `json:"retain_days,omitempty"`
Mode string `json:"mode,omitempty"`
AlertsDays float64 `json:"alerts_days,omitempty"`
DetectionDays float64 `json:"detections_days,omitempty"`
PreCapture int `json:"pre_capture,omitempty"`
PostCapture int `json:"post_capture,omitempty"`
}
type DetectConfig struct {
Enabled bool `json:"enabled"`
FPS int `json:"fps,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type MotionConfig struct {
Enabled bool `json:"enabled"`
Threshold int `json:"threshold,omitempty"`
ContourArea int `json:"contour_area,omitempty"`
}
type FFmpegConfig struct {
HWAccel string `json:"hwaccel,omitempty"`
GPU int `json:"gpu,omitempty"`
}
type LiveConfig struct {
Height int `json:"height,omitempty"`
Quality int `json:"quality,omitempty"`
}
type AudioConfig struct {
Enabled bool `json:"enabled"`
Filters []string `json:"filters,omitempty"`
}
type BirdseyeConfig struct {
Enabled bool `json:"enabled"`
Mode string `json:"mode,omitempty"`
}
type ONVIFConfig struct {
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
User string `json:"user,omitempty"`
Password string `json:"password,omitempty"`
AutoTracking bool `json:"autotracking,omitempty"`
RequiredZones []string `json:"required_zones,omitempty"`
}
type PTZConfig struct {
Enabled bool `json:"enabled"`
Presets map[string]string `json:"presets,omitempty"`
}
type BoolConfig struct {
Enabled bool `json:"enabled"`
}
type UIConfig struct {
Order int `json:"order,omitempty"`
Dashboard bool `json:"dashboard"`
}
type Response struct {
Config string `json:"config"`
Diff []DiffLine `json:"diff"`
}
type DiffLine struct {
Line int `json:"line"`
Text string `json:"text"`
Type string `json:"type"` // context, added, removed
}
+265
View File
@@ -0,0 +1,265 @@
package generate
import (
"fmt"
"strings"
)
func writeStreamLines(b *strings.Builder, info *cameraInfo) {
fmt.Fprintf(b, " '%s':\n", info.MainStreamName)
fmt.Fprintf(b, " - %s\n", info.MainSource)
if info.SubStreamName != "" {
fmt.Fprintf(b, " '%s':\n", info.SubStreamName)
fmt.Fprintf(b, " - %s\n", info.SubSource)
}
b.WriteByte('\n')
}
func writeCameraBlock(b *strings.Builder, info *cameraInfo, req *Request) {
fmt.Fprintf(b, " %s:\n", info.CameraName)
b.WriteString(" ffmpeg:\n")
writeFFmpegGlobal(b, req)
b.WriteString(" inputs:\n")
if info.SubStreamName != "" {
writeInput(b, info.SubPath, info.SubInputArgs, "detect")
writeInput(b, info.MainPath, info.MainInputArgs, "record")
} else {
writeInput(b, info.MainPath, info.MainInputArgs, "detect", "record")
}
writeLive(b, info, req)
writeDetect(b, req)
writeObjects(b, req)
writeMotion(b, req)
writeRecord(b, req)
writeSnapshots(b, req)
writeAudio(b, req)
writeBirdseye(b, req)
writeONVIF(b, req)
writeNotifications(b, req)
writeUI(b, req)
b.WriteByte('\n')
}
func writeInput(b *strings.Builder, path, inputArgs string, roles ...string) {
fmt.Fprintf(b, " - path: %s\n", path)
fmt.Fprintf(b, " input_args: %s\n", inputArgs)
b.WriteString(" roles:\n")
for _, r := range roles {
fmt.Fprintf(b, " - %s\n", r)
}
}
func writeFFmpegGlobal(b *strings.Builder, req *Request) {
if req.FFmpeg == nil {
return
}
if req.FFmpeg.HWAccel != "" && req.FFmpeg.HWAccel != "auto" {
fmt.Fprintf(b, " hwaccel_args: %s\n", req.FFmpeg.HWAccel)
}
if req.FFmpeg.GPU > 0 {
fmt.Fprintf(b, " gpu: %d\n", req.FFmpeg.GPU)
}
}
func writeLive(b *strings.Builder, info *cameraInfo, req *Request) {
if info.SubStreamName == "" && req.Live == nil {
return
}
b.WriteString(" live:\n")
if info.SubStreamName != "" {
b.WriteString(" streams:\n")
fmt.Fprintf(b, " Main Stream: %s\n", info.MainStreamName)
fmt.Fprintf(b, " Sub Stream: %s\n", info.SubStreamName)
}
if req.Live != nil {
if req.Live.Height > 0 {
fmt.Fprintf(b, " height: %d\n", req.Live.Height)
}
if req.Live.Quality > 0 {
fmt.Fprintf(b, " quality: %d\n", req.Live.Quality)
}
}
}
func writeDetect(b *strings.Builder, req *Request) {
if req.Detect == nil {
b.WriteString(" detect:\n enabled: true\n")
return
}
b.WriteString(" detect:\n")
fmt.Fprintf(b, " enabled: %t\n", req.Detect.Enabled)
if req.Detect.FPS > 0 {
fmt.Fprintf(b, " fps: %d\n", req.Detect.FPS)
}
if req.Detect.Width > 0 {
fmt.Fprintf(b, " width: %d\n", req.Detect.Width)
}
if req.Detect.Height > 0 {
fmt.Fprintf(b, " height: %d\n", req.Detect.Height)
}
}
func writeObjects(b *strings.Builder, req *Request) {
objects := req.Objects
if len(objects) == 0 {
objects = []string{"person"}
}
b.WriteString(" objects:\n track:\n")
for _, obj := range objects {
fmt.Fprintf(b, " - %s\n", obj)
}
}
func writeMotion(b *strings.Builder, req *Request) {
if req.Motion == nil {
return
}
b.WriteString(" motion:\n")
fmt.Fprintf(b, " enabled: %t\n", req.Motion.Enabled)
if req.Motion.Threshold > 0 {
fmt.Fprintf(b, " threshold: %d\n", req.Motion.Threshold)
}
if req.Motion.ContourArea > 0 {
fmt.Fprintf(b, " contour_area: %d\n", req.Motion.ContourArea)
}
}
func writeRecord(b *strings.Builder, req *Request) {
if req.Record == nil {
b.WriteString(" record:\n enabled: true\n")
return
}
b.WriteString(" record:\n")
fmt.Fprintf(b, " enabled: %t\n", req.Record.Enabled)
if req.Record.RetainDays > 0 || req.Record.Mode != "" {
b.WriteString(" retain:\n")
if req.Record.RetainDays > 0 {
fmt.Fprintf(b, " days: %g\n", req.Record.RetainDays)
}
if req.Record.Mode != "" {
fmt.Fprintf(b, " mode: %s\n", req.Record.Mode)
}
}
if req.Record.AlertsDays > 0 || req.Record.PreCapture > 0 || req.Record.PostCapture > 0 {
b.WriteString(" alerts:\n")
if req.Record.AlertsDays > 0 {
fmt.Fprintf(b, " retain:\n days: %g\n", req.Record.AlertsDays)
}
if req.Record.PreCapture > 0 {
fmt.Fprintf(b, " pre_capture: %d\n", req.Record.PreCapture)
}
if req.Record.PostCapture > 0 {
fmt.Fprintf(b, " post_capture: %d\n", req.Record.PostCapture)
}
}
if req.Record.DetectionDays > 0 {
fmt.Fprintf(b, " detections:\n retain:\n days: %g\n", req.Record.DetectionDays)
}
}
func writeSnapshots(b *strings.Builder, req *Request) {
if req.Snapshots == nil || !req.Snapshots.Enabled {
return
}
b.WriteString(" snapshots:\n enabled: true\n")
}
func writeAudio(b *strings.Builder, req *Request) {
if req.Audio == nil || !req.Audio.Enabled {
return
}
b.WriteString(" audio:\n enabled: true\n")
if len(req.Audio.Filters) > 0 {
b.WriteString(" filters:\n")
for _, f := range req.Audio.Filters {
fmt.Fprintf(b, " - %s\n", f)
}
}
}
func writeBirdseye(b *strings.Builder, req *Request) {
if req.Birdseye == nil {
return
}
b.WriteString(" birdseye:\n")
fmt.Fprintf(b, " enabled: %t\n", req.Birdseye.Enabled)
if req.Birdseye.Mode != "" {
fmt.Fprintf(b, " mode: %s\n", req.Birdseye.Mode)
}
}
func writeONVIF(b *strings.Builder, req *Request) {
if req.ONVIF == nil || req.ONVIF.Host == "" {
return
}
b.WriteString(" onvif:\n")
fmt.Fprintf(b, " host: %s\n", req.ONVIF.Host)
port := req.ONVIF.Port
if port == 0 {
port = 80
}
fmt.Fprintf(b, " port: %d\n", port)
if req.ONVIF.User != "" {
fmt.Fprintf(b, " user: %s\n", req.ONVIF.User)
fmt.Fprintf(b, " password: %s\n", req.ONVIF.Password)
}
if req.ONVIF.AutoTracking {
b.WriteString(" autotracking:\n enabled: true\n")
if len(req.ONVIF.RequiredZones) > 0 {
b.WriteString(" required_zones:\n")
for _, z := range req.ONVIF.RequiredZones {
fmt.Fprintf(b, " - %s\n", z)
}
}
}
if req.PTZ != nil && len(req.PTZ.Presets) > 0 {
b.WriteString(" ptz:\n presets:\n")
for name, token := range req.PTZ.Presets {
fmt.Fprintf(b, " %s: %s\n", name, token)
}
}
}
func writeNotifications(b *strings.Builder, req *Request) {
if req.Notifications == nil || !req.Notifications.Enabled {
return
}
b.WriteString(" notifications:\n enabled: true\n")
}
func writeUI(b *strings.Builder, req *Request) {
if req.UI == nil {
return
}
b.WriteString(" ui:\n")
if req.UI.Order > 0 {
fmt.Fprintf(b, " order: %d\n", req.UI.Order)
}
if !req.UI.Dashboard {
b.WriteString(" dashboard: false\n")
}
}