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
+137
View File
@@ -0,0 +1,137 @@
package camdb
import (
"database/sql"
"strings"
)
type Result struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
}
// SearchAll returns all presets + all brands, no models
func SearchAll(db *sql.DB) ([]Result, error) {
var results []Result
rows, err := db.Query("SELECT preset_id, name FROM presets ORDER BY preset_id")
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id, name string
if err = rows.Scan(&id, &name); err != nil {
return nil, err
}
results = append(results, Result{Type: "preset", ID: "p:" + id, Name: name})
}
rows, err = db.Query("SELECT brand_id, brand FROM brands ORDER BY brand LIMIT ?", 50-len(results))
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id, name string
if err = rows.Scan(&id, &name); err != nil {
return nil, err
}
results = append(results, Result{Type: "brand", ID: "b:" + id, Name: name})
}
return results, nil
}
// SearchQuery searches presets, brands, models by query string (limit 50 total).
// Supports: "model", "brand model", "model brand" -- each word matches independently.
func SearchQuery(db *sql.DB, q string) ([]Result, error) {
var results []Result
like := "%" + q + "%"
// presets
rows, err := db.Query(
"SELECT preset_id, name FROM presets WHERE preset_id LIKE ? OR name LIKE ? ORDER BY preset_id",
like, like,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id, name string
if err = rows.Scan(&id, &name); err != nil {
return nil, err
}
results = append(results, Result{Type: "preset", ID: "p:" + id, Name: name})
}
// brands
rows, err = db.Query(
"SELECT brand_id, brand FROM brands WHERE brand_id LIKE ? OR brand LIKE ? ORDER BY brand LIMIT ?",
like, like, 50-len(results),
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id, name string
if err = rows.Scan(&id, &name); err != nil {
return nil, err
}
results = append(results, Result{Type: "brand", ID: "b:" + id, Name: name})
}
if len(results) >= 50 {
return results, nil
}
// models -- each word must match brand or model
words := strings.Fields(q)
where := ""
args := make([]any, 0, len(words)+1)
for i, w := range words {
if i > 0 {
where += " AND "
}
where += "(b.brand LIKE ? OR b.brand_id LIKE ? OR sm.model LIKE ?)"
p := "%" + w + "%"
args = append(args, p, p, p)
}
args = append(args, 50-len(results))
rows, err = db.Query(
`SELECT DISTINCT b.brand_id, b.brand, sm.model
FROM stream_models sm
JOIN streams s ON s.id = sm.stream_id
JOIN brands b ON b.brand_id = s.brand_id
WHERE `+where+`
ORDER BY b.brand, sm.model
LIMIT ?`,
args...,
)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var brandID, brand, model string
if err = rows.Scan(&brandID, &brand, &model); err != nil {
return nil, err
}
results = append(results, Result{
Type: "model",
ID: "m:" + brandID + ":" + model,
Name: brand + ": " + model,
})
}
return results, nil
}
+202
View File
@@ -0,0 +1,202 @@
package camdb
import (
"database/sql"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
)
var defaultPorts = map[string]int{
"rtsp": 554, "rtsps": 322, "http": 80, "https": 443,
"rtmp": 1935, "mms": 554, "rtp": 5004,
}
type StreamParams struct {
IDs string
IP string
User string
Pass string
Channel int
Ports map[int]bool // nil = no filter
}
type raw struct {
url, protocol string
port int
}
// BuildStreams resolves IDs to full stream URLs with credentials and placeholders substituted
func BuildStreams(db *sql.DB, p *StreamParams) ([]string, error) {
var raws []raw
for _, id := range strings.Split(p.IDs, ",") {
id = strings.TrimSpace(id)
if id == "" {
continue
}
var rows *sql.Rows
var err error
switch {
case strings.HasPrefix(id, "b:"):
brandID := id[2:]
rows, err = db.Query(
"SELECT url, protocol, port FROM streams WHERE brand_id = ?", brandID,
)
case strings.HasPrefix(id, "m:"):
parts := strings.SplitN(id[2:], ":", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("camdb: invalid model id: %s", id)
}
rows, err = db.Query(
`SELECT s.url, s.protocol, s.port
FROM stream_models sm
JOIN streams s ON s.id = sm.stream_id
WHERE s.brand_id = ? AND sm.model = ?`,
parts[0], parts[1],
)
case strings.HasPrefix(id, "p:"):
presetID := id[2:]
rows, err = db.Query(
"SELECT url, protocol, port FROM preset_streams WHERE preset_id = ?", presetID,
)
default:
return nil, fmt.Errorf("camdb: unknown id prefix: %s", id)
}
if err != nil {
return nil, err
}
found := false
for rows.Next() {
var r raw
if err = rows.Scan(&r.url, &r.protocol, &r.port); err != nil {
rows.Close()
return nil, err
}
raws = append(raws, r)
found = true
}
rows.Close()
if !found {
return nil, fmt.Errorf("camdb: not found: %s", id)
}
}
// build full URLs, deduplicate
seen := map[string]bool{}
var streams []string
for _, r := range raws {
if len(streams) >= 20000 {
break
}
port := r.port
if port == 0 {
if p, ok := defaultPorts[r.protocol]; ok {
port = p
} else {
port = 80
}
}
if p.Ports != nil && !p.Ports[port] {
continue
}
u := buildURL(r.protocol, r.url, p.IP, port, p.User, p.Pass, p.Channel)
if seen[u] {
continue
}
seen[u] = true
streams = append(streams, u)
}
return streams, nil
}
// ValidateID checks if id format is valid
func ValidateID(id string) error {
switch {
case strings.HasPrefix(id, "b:"):
if len(id) < 3 {
return errors.New("camdb: empty brand id")
}
case strings.HasPrefix(id, "m:"):
if strings.Count(id, ":") < 2 {
return fmt.Errorf("camdb: invalid model id: %s", id)
}
case strings.HasPrefix(id, "p:"):
if len(id) < 3 {
return errors.New("camdb: empty preset id")
}
default:
return fmt.Errorf("camdb: unknown prefix: %s", id)
}
return nil
}
// internals
func buildURL(protocol, path, ip string, port int, user, pass string, channel int) string {
path = replacePlaceholders(path, ip, port, user, pass, channel)
var auth string
if user != "" {
auth = user + ":" + pass + "@"
}
host := ip
if p, ok := defaultPorts[protocol]; !ok || p != port {
host = ip + ":" + strconv.Itoa(port)
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return protocol + "://" + auth + host + path
}
func replacePlaceholders(s, ip string, port int, user, pass string, channel int) string {
auth := ""
if user != "" && pass != "" {
auth = base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
}
pairs := []string{
"[CHANNEL]", strconv.Itoa(channel),
"[channel]", strconv.Itoa(channel),
"{CHANNEL}", strconv.Itoa(channel),
"{channel}", strconv.Itoa(channel),
"[CHANNEL+1]", strconv.Itoa(channel + 1),
"[channel+1]", strconv.Itoa(channel + 1),
"{CHANNEL+1}", strconv.Itoa(channel + 1),
"{channel+1}", strconv.Itoa(channel + 1),
"[USERNAME]", user, "[username]", user,
"[USER]", user, "[user]", user,
"[PASSWORD]", pass, "[password]", pass,
"[PASWORD]", pass, "[pasword]", pass,
"[PASS]", pass, "[pass]", pass,
"[PWD]", pass, "[pwd]", pass,
"[WIDTH]", "640", "[width]", "640",
"[HEIGHT]", "480", "[height]", "480",
"[IP]", ip, "[ip]", ip,
"[PORT]", strconv.Itoa(port), "[port]", strconv.Itoa(port),
"[AUTH]", auth, "[auth]", auth,
"[TOKEN]", "", "[token]", "",
}
r := strings.NewReplacer(pairs...)
return r.Replace(s)
}