From f34a7b96c7700bdccbfb973c6b491214c3d57601 Mon Sep 17 00:00:00 2001 From: eduard256 Date: Thu, 26 Mar 2026 22:45:32 +0000 Subject: [PATCH] Add go2rtc module, test/config/urls pages, Frigate config fixes --- internal/frigate/frigate.go | 10 +- internal/go2rtc/go2rtc.go | 108 +++++ main.go | 2 + pkg/generate/config.go | 16 +- pkg/generate/diff.go | 36 -- pkg/generate/insert.go | 11 +- pkg/generate/models.go | 10 +- www/config.html | 820 ++++++++++++++++++++++++++++++++++++ www/create.html | 99 ++++- www/go2rtc.html | 344 +++++++++++++++ www/test.html | 687 ++++++++++++++++++++++++++++++ www/urls.html | 320 ++++++++++++++ 12 files changed, 2411 insertions(+), 52 deletions(-) create mode 100644 internal/go2rtc/go2rtc.go delete mode 100644 pkg/generate/diff.go create mode 100644 www/config.html create mode 100644 www/go2rtc.html create mode 100644 www/test.html create mode 100644 www/urls.html diff --git a/internal/frigate/frigate.go b/internal/frigate/frigate.go index d5eda6a..58cda49 100644 --- a/internal/frigate/frigate.go +++ b/internal/frigate/frigate.go @@ -1,6 +1,7 @@ package frigate import ( + "encoding/json" "io" "net/http" "sync" @@ -99,10 +100,17 @@ func apiConfig(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(resp.Body) + // Frigate /api/config/raw returns JSON-encoded string, unquote it + config := string(body) + var unquoted string + if err := json.Unmarshal(body, &unquoted); err == nil { + config = unquoted + } + api.ResponseJSON(w, map[string]any{ "connected": true, "url": url, - "config": string(body), + "config": config, }) } diff --git a/internal/go2rtc/go2rtc.go b/internal/go2rtc/go2rtc.go new file mode 100644 index 0000000..75a3abb --- /dev/null +++ b/internal/go2rtc/go2rtc.go @@ -0,0 +1,108 @@ +package go2rtc + +import ( + "io" + "net/http" + "sync" + "time" + + "github.com/eduard256/strix/internal/api" + "github.com/eduard256/strix/internal/app" + "github.com/rs/zerolog" +) + +var log zerolog.Logger + +var go2rtcURL string +var go2rtcOnce sync.Once + +var candidates = []string{ + "http://localhost:1984", + "http://localhost:11984", +} + +const probeTimeout = 50 * time.Millisecond +const requestTimeout = 5 * time.Second + +func Init() { + log = app.GetLogger("go2rtc") + + if url := app.Env("STRIX_GO2RTC_URL", ""); url != "" { + go2rtcURL = url + log.Info().Str("url", go2rtcURL).Msg("[go2rtc] using STRIX_GO2RTC_URL") + } + + api.HandleFunc("api/go2rtc/streams", apiStreams) +} + +func getURL() string { + if go2rtcURL != "" { + return go2rtcURL + } + + go2rtcOnce.Do(func() { + go2rtcURL = probe() + if go2rtcURL != "" { + log.Info().Str("url", go2rtcURL).Msg("[go2rtc] discovered") + } + }) + + return go2rtcURL +} + +func probe() string { + client := &http.Client{Timeout: probeTimeout} + + for _, url := range candidates { + resp, err := client.Get(url + "/api") + if err != nil { + continue + } + resp.Body.Close() + if resp.StatusCode == 200 { + return url + } + } + + return "" +} + +// PUT /api/go2rtc/streams?name=...&src=... -- proxy to go2rtc +func apiStreams(w http.ResponseWriter, r *http.Request) { + if r.Method != "PUT" { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + url := getURL() + if url == "" { + api.ResponseJSON(w, map[string]any{"success": false, "error": "go2rtc not found"}) + return + } + + // forward query params as-is + target := url + "/api/streams?" + r.URL.RawQuery + + client := &http.Client{Timeout: requestTimeout} + req, err := http.NewRequest("PUT", target, nil) + if err != nil { + api.ResponseJSON(w, map[string]any{"success": false, "error": err.Error()}) + return + } + + resp, err := client.Do(req) + if err != nil { + api.ResponseJSON(w, map[string]any{"success": false, "error": err.Error()}) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + w.Header().Set("Content-Type", "application/json") + if resp.StatusCode == 200 { + api.ResponseJSON(w, map[string]any{"success": true}) + } else { + api.ResponseJSON(w, map[string]any{"success": false, "error": string(body)}) + } +} diff --git a/main.go b/main.go index eb097da..0b17abe 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "github.com/eduard256/strix/internal/app" "github.com/eduard256/strix/internal/frigate" "github.com/eduard256/strix/internal/generate" + "github.com/eduard256/strix/internal/go2rtc" "github.com/eduard256/strix/internal/probe" "github.com/eduard256/strix/internal/search" "github.com/eduard256/strix/internal/test" @@ -26,6 +27,7 @@ func main() { {"probe", probe.Init}, {"generate", generate.Init}, {"frigate", frigate.Init}, + {"go2rtc", go2rtc.Init}, } for _, m := range modules { diff --git a/pkg/generate/config.go b/pkg/generate/config.go index 1296a87..eb7e623 100644 --- a/pkg/generate/config.go +++ b/pkg/generate/config.go @@ -26,9 +26,17 @@ func Generate(req *Request) (*Response, error) { } } - if strings.TrimSpace(req.ExistingConfig) == "" { + existing := strings.TrimSpace(req.ExistingConfig) + + // generate from scratch if no config or config has no go2rtc streams section + if existing == "" || !strings.Contains(existing, "go2rtc:") { config := newConfig(info, req) - return &Response{Config: config, Diff: fullDiff(config)}, nil + lines := strings.Count(config, "\n") + 1 + added := make([]int, lines) + for i := range added { + added[i] = i + 1 + } + return &Response{Config: config, Added: added}, nil } return addToConfig(req.ExistingConfig, info, req) @@ -124,7 +132,7 @@ 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("record:\n enabled: true\n\n") b.WriteString("go2rtc:\n streams:\n") writeStreamLines(&b, info) @@ -132,7 +140,7 @@ func newConfig(info *cameraInfo, req *Request) string { b.WriteString("cameras:\n") writeCameraBlock(&b, info, req) - b.WriteString("version: 0.18-0\n") + b.WriteString("version: 0.17-0\n") return b.String() } diff --git a/pkg/generate/diff.go b/pkg/generate/diff.go deleted file mode 100644 index 0750d4e..0000000 --- a/pkg/generate/diff.go +++ /dev/null @@ -1,36 +0,0 @@ -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 -} diff --git a/pkg/generate/insert.go b/pkg/generate/insert.go index d82f6e7..d3f35d6 100644 --- a/pkg/generate/insert.go +++ b/pkg/generate/insert.go @@ -65,8 +65,15 @@ func addToConfig(existing string, info *cameraInfo, req *Request) (*Response, er result = append(result, rest[split:]...) config := strings.Join(result, "\n") - diff := diffWithContext(result, added, 3) - return &Response{Config: config, Diff: diff}, nil + + addedLines := make([]int, 0, len(added)) + for i := range result { + if added[i] { + addedLines = append(addedLines, i+1) + } + } + + return &Response{Config: config, Added: addedLines}, nil } func dedup(info *cameraInfo, cams, streams map[string]bool) *cameraInfo { diff --git a/pkg/generate/models.go b/pkg/generate/models.go index f597a9c..ee61e41 100644 --- a/pkg/generate/models.go +++ b/pkg/generate/models.go @@ -106,12 +106,6 @@ type UIConfig struct { } 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 + Config string `json:"config"` + Added []int `json:"added"` // 1-based line numbers of added lines } diff --git a/www/config.html b/www/config.html new file mode 100644 index 0000000..e5a71af --- /dev/null +++ b/www/config.html @@ -0,0 +1,820 @@ + + + + + + + Strix - Configuration + + + + +
+ +

Frigate Configuration

+ +
+ +
+
+ + +
+
+ +
+ +
+ +
+ + + + +
+
Camera Name
+ +
+ +
+ + + + + + +
+ + +
+
+
+ Generated Config +
+ + +
+
+
Loading...
+ +
+
+
+
+ + + + + + + diff --git a/www/create.html b/www/create.html index 5bc63b6..360b300 100644 --- a/www/create.html +++ b/www/create.html @@ -359,7 +359,11 @@ var data = await r.json(); dbStreams = data.streams || []; - renderAll(); + if (dbStreams.length === 0 && customStreams.length === 0) { + renderEmpty(); + } else { + renderAll(); + } } catch (e) { renderError('Connection error: ' + e.message); } @@ -518,6 +522,99 @@ content.appendChild(div); } + function renderEmpty() { + var content = document.getElementById('content'); + while (content.firstChild) content.removeChild(content.firstChild); + + var box = document.createElement('div'); + box.style.cssText = 'text-align:center; padding:2.5rem 1.5rem; background:var(--bg-secondary); border:1px solid var(--border-color); border-radius:8px;'; + + var title = document.createElement('div'); + title.style.cssText = 'font-size:1.125rem; font-weight:600; color:var(--text-primary); margin-bottom:0.75rem;'; + title.textContent = 'No streams found for this camera'; + + var msg = document.createElement('div'); + msg.style.cssText = 'font-size:0.875rem; color:var(--text-secondary); line-height:1.6; margin-bottom:1.5rem;'; + msg.textContent = 'The Strix database does not have stream patterns for this device yet. You can help by adding your camera model to the database.'; + + var link = document.createElement('a'); + var p = new URLSearchParams(); + if (vendor) p.set('brand', vendor); + if (model) p.set('model', model); + if (mac && mac.length >= 8) p.set('mac_prefix', mac.substring(0, 8)); + link.href = 'https://gostrix.github.io/#/contribute?' + p.toString(); + link.target = '_blank'; + link.style.cssText = 'display:inline-flex; align-items:center; gap:0.5rem; padding:0.75rem 1.25rem; background:var(--purple-primary); color:white; border-radius:8px; text-decoration:none; font-size:0.875rem; font-weight:600; transition:opacity 150ms;'; + link.textContent = 'Add your camera to Strix DB'; + link.onmouseover = function() { link.style.opacity = '0.85'; }; + link.onmouseout = function() { link.style.opacity = '1'; }; + + var issueLink = document.createElement('a'); + issueLink.href = 'https://github.com/eduard256/Strix/issues'; + issueLink.target = '_blank'; + issueLink.style.cssText = 'display:inline-flex; align-items:center; gap:0.5rem; padding:0.75rem 1.25rem; background:var(--bg-tertiary); color:var(--text-secondary); border:1px solid var(--border-color); border-radius:8px; text-decoration:none; font-size:0.875rem; font-weight:600; transition:all 150ms;'; + issueLink.textContent = 'Report Issue'; + issueLink.onmouseover = function() { issueLink.style.borderColor = 'var(--purple-primary)'; issueLink.style.color = 'var(--purple-light)'; }; + issueLink.onmouseout = function() { issueLink.style.borderColor = 'var(--border-color)'; issueLink.style.color = 'var(--text-secondary)'; }; + + var btns = document.createElement('div'); + btns.style.cssText = 'display:flex; gap:0.75rem; justify-content:center; flex-wrap:wrap;'; + btns.appendChild(link); + btns.appendChild(issueLink); + + var or = document.createElement('div'); + or.style.cssText = 'margin:1.25rem 0; color:var(--text-tertiary); font-size:0.75rem;'; + or.textContent = 'or add a custom stream URL below'; + + box.appendChild(title); + box.appendChild(msg); + box.appendChild(btns); + box.appendChild(or); + content.appendChild(box); + + // still show add section below + renderAddSection(content); + updateButton(); + } + + function renderAddSection(content) { + var addSection = document.createElement('div'); + addSection.className = 'add-section'; + var addRow = document.createElement('div'); + addRow.className = 'add-row'; + var addInput = document.createElement('input'); + addInput.className = 'add-input'; + addInput.type = 'text'; + addInput.placeholder = 'rtsp://user:pass@host/path or bubble://...'; + addInput.spellcheck = false; + addInput.value = pendingInput; + var addBtn = document.createElement('button'); + addBtn.className = 'btn-add'; + addBtn.type = 'button'; + addBtn.textContent = '+'; + + function addCustom() { + var v = addInput.value.trim(); + if (!v) return; + if (v.indexOf('://') === -1) { showToast('URL must include protocol (rtsp://, http://, bubble://, ...)'); return; } + if (customStreams.indexOf(v) !== -1 || dbStreams.indexOf(v) !== -1) { showToast('This URL is already in the list'); return; } + customStreams.push(v); + pendingInput = ''; + renderAll(); + var newInput = document.querySelector('.add-input'); + if (newInput) newInput.focus(); + showContributeBanner(v); + } + + addBtn.addEventListener('click', addCustom); + addInput.addEventListener('keypress', function(e) { if (e.key === 'Enter') addCustom(); }); + addInput.addEventListener('input', function() { pendingInput = addInput.value; }); + addRow.appendChild(addInput); + addRow.appendChild(addBtn); + addSection.appendChild(addRow); + content.appendChild(addSection); + } + // test button document.getElementById('btn-test').addEventListener('click', startTest); diff --git a/www/go2rtc.html b/www/go2rtc.html new file mode 100644 index 0000000..a40fe5b --- /dev/null +++ b/www/go2rtc.html @@ -0,0 +1,344 @@ + + + + + + + Strix - Add to go2rtc + + + + +
+
+ + +

Add to go2rtc

+ + +
+ + + + + + +
+ + +
+
Main Stream Name
+ +
Name used in go2rtc config and Frigate
+
+ + + + + + +
+
+
+ + + + + + + diff --git a/www/test.html b/www/test.html new file mode 100644 index 0000000..da2bbd6 --- /dev/null +++ b/www/test.html @@ -0,0 +1,687 @@ + + + + + + + Strix - Stream Testing + + + + +
+
+ + +
+

Stream Testing

+ +
+ +
+
+
+
+ running +
+ +
+
+
0
total
+
0
tested
+
0
alive
+
0
screenshots
+
+
+
+
+
+ +
+
+
+ + + + + + + diff --git a/www/urls.html b/www/urls.html new file mode 100644 index 0000000..2ed9741 --- /dev/null +++ b/www/urls.html @@ -0,0 +1,320 @@ + + + + + + + Strix - Stream URLs + + + + +
+
+ + +

Your Stream URLs

+

Copy these URLs to use in your NVR, media player, or streaming software.

+ +
+ + + +
+ +
+
How to use
+
Add these URLs to your NVR software (Frigate, Blue Iris, Shinobi, etc.) or stream player (VLC, go2rtc). The main stream is high resolution for recording, the sub stream is lower resolution for real-time detection.
+
+ +
+
+
+ + + + + + +