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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user