Skip to content

Commit eff2a79

Browse files
Brandon Salzbergclaude
andcommitted
Add live video streaming command
- `rhombus live <camera-name-or-uuid>` opens a live DASH stream in the browser - Fuzzy camera name matching (same as --partner-org) - Uses federated session token for cookie-free auth (default 1 hour) - dash.js player with Rhombus-styled dark theme - XHR interceptor appends auth params to all segment requests - Auto-reconnect on playback errors, click to unmute Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a366c7c commit eff2a79

1 file changed

Lines changed: 319 additions & 0 deletions

File tree

cmd/live.go

Lines changed: 319 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,319 @@
1+
package cmd
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"os/exec"
8+
"path/filepath"
9+
"runtime"
10+
11+
"github.com/RhombusSystems/rhombus-cli/internal/client"
12+
"github.com/RhombusSystems/rhombus-cli/internal/config"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
func init() {
17+
liveCmd := &cobra.Command{
18+
Use: "live [camera-name-or-uuid]",
19+
Short: "Open a live video stream in the browser",
20+
Long: "Opens a live video feed from a Rhombus camera in your default browser. Accepts a camera UUID or partial name for fuzzy matching.",
21+
Args: cobra.ExactArgs(1),
22+
RunE: runLive,
23+
}
24+
liveCmd.Flags().Int("duration", 3600, "Federated token duration in seconds")
25+
rootCmd.AddCommand(liveCmd)
26+
}
27+
28+
func runLive(cmd *cobra.Command, args []string) error {
29+
cfg := config.LoadFromCmd(cmd)
30+
duration, _ := cmd.Flags().GetInt("duration")
31+
cameraArg := args[0]
32+
33+
// Resolve camera UUID
34+
cameraUUID, cameraName, err := resolveCamera(cfg, cameraArg)
35+
if err != nil {
36+
return err
37+
}
38+
39+
fmt.Printf("Opening live stream for %s...\n", cameraName)
40+
41+
// Generate federated session token
42+
fedResp, err := client.APICall(cfg, "/api/org/generateFederatedSessionToken", map[string]any{
43+
"durationSec": duration,
44+
})
45+
if err != nil {
46+
return fmt.Errorf("generating federated token: %w", err)
47+
}
48+
federatedToken, _ := fedResp["federatedSessionToken"].(string)
49+
if federatedToken == "" {
50+
return fmt.Errorf("no federated token returned")
51+
}
52+
53+
// Get media URIs
54+
mediaResp, err := client.APICall(cfg, "/api/camera/getMediaUris", map[string]any{
55+
"cameraUuid": cameraUUID,
56+
})
57+
if err != nil {
58+
return fmt.Errorf("getting media URIs: %w", err)
59+
}
60+
61+
mpdUri, _ := mediaResp["wanLiveMpdUri"].(string)
62+
if mpdUri == "" {
63+
return fmt.Errorf("no live MPD URI available for this camera")
64+
}
65+
66+
// Append federated token auth
67+
streamURL := mpdUri + "?x-auth-scheme=federated-token&x-auth-ft=" + federatedToken
68+
69+
// Generate HTML file
70+
htmlPath, err := generatePlayerHTML(cameraName, streamURL)
71+
if err != nil {
72+
return fmt.Errorf("generating player: %w", err)
73+
}
74+
75+
// Open in browser
76+
openInBrowser("file://" + htmlPath)
77+
78+
fmt.Printf("Live stream opened in browser.\n")
79+
fmt.Printf("Token expires in %d seconds.\n", duration)
80+
return nil
81+
}
82+
83+
func resolveCamera(cfg config.Config, cameraArg string) (uuid string, name string, err error) {
84+
// If it looks like a UUID (22 chars base64), use directly
85+
if looksLikeUUID(cameraArg) {
86+
return cameraArg, cameraArg, nil
87+
}
88+
89+
// Otherwise, fuzzy match by name
90+
resp, err := client.APICall(cfg, "/api/camera/getMinimalCameraStateList", map[string]any{})
91+
if err != nil {
92+
return "", "", fmt.Errorf("fetching cameras: %w", err)
93+
}
94+
95+
cameras, _ := resp["cameraStates"].([]any)
96+
if len(cameras) == 0 {
97+
return "", "", fmt.Errorf("no cameras found")
98+
}
99+
100+
search := cameraArg
101+
var matches []struct{ uuid, name string }
102+
103+
for _, c := range cameras {
104+
cam, ok := c.(map[string]any)
105+
if !ok {
106+
continue
107+
}
108+
camName, _ := cam["name"].(string)
109+
camUUID, _ := cam["uuid"].(string)
110+
if camName == "" || camUUID == "" {
111+
continue
112+
}
113+
if containsFold(camName, search) {
114+
matches = append(matches, struct{ uuid, name string }{camUUID, camName})
115+
}
116+
}
117+
118+
if len(matches) == 0 {
119+
return "", "", fmt.Errorf("no cameras matching \"%s\"", cameraArg)
120+
}
121+
if len(matches) == 1 {
122+
return matches[0].uuid, matches[0].name, nil
123+
}
124+
125+
// Multiple matches — pick the first exact-ish match or list them
126+
fmt.Fprintf(os.Stderr, "Multiple cameras match \"%s\":\n", cameraArg)
127+
for i, m := range matches {
128+
fmt.Fprintf(os.Stderr, " [%d] %s (%s)\n", i+1, m.name, m.uuid)
129+
}
130+
131+
// For non-interactive use, pick the first
132+
return matches[0].uuid, matches[0].name, nil
133+
}
134+
135+
func containsFold(s, substr string) bool {
136+
sLower := make([]byte, len(s))
137+
subLower := make([]byte, len(substr))
138+
for i := range s {
139+
if s[i] >= 'A' && s[i] <= 'Z' {
140+
sLower[i] = s[i] + 32
141+
} else {
142+
sLower[i] = s[i]
143+
}
144+
}
145+
for i := range substr {
146+
if substr[i] >= 'A' && substr[i] <= 'Z' {
147+
subLower[i] = substr[i] + 32
148+
} else {
149+
subLower[i] = substr[i]
150+
}
151+
}
152+
return contains(string(sLower), string(subLower))
153+
}
154+
155+
func contains(s, substr string) bool {
156+
for i := 0; i+len(substr) <= len(s); i++ {
157+
if s[i:i+len(substr)] == substr {
158+
return true
159+
}
160+
}
161+
return false
162+
}
163+
164+
func generatePlayerHTML(cameraName, streamURL string) (string, error) {
165+
tmpDir := filepath.Join(os.TempDir(), "rhombus-live")
166+
if err := os.MkdirAll(tmpDir, 0755); err != nil {
167+
return "", err
168+
}
169+
170+
htmlPath := filepath.Join(tmpDir, "live.html")
171+
172+
// Escape for JSON embedding
173+
streamURLJSON, _ := json.Marshal(streamURL)
174+
cameraNameJSON, _ := json.Marshal(cameraName)
175+
176+
html := fmt.Sprintf(`<!DOCTYPE html>
177+
<html>
178+
<head>
179+
<title>%s — Rhombus Live</title>
180+
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
181+
<link rel="preconnect" href="https://fonts.googleapis.com">
182+
<link href="https://fonts.googleapis.com/css2?family=Nunito+Sans:wght@400;700;900&display=swap" rel="stylesheet">
183+
<style>
184+
* { margin: 0; padding: 0; box-sizing: border-box; }
185+
body {
186+
font-family: 'Nunito Sans', sans-serif;
187+
background: #0B0C0D;
188+
color: #FCFEFF;
189+
display: flex;
190+
flex-direction: column;
191+
align-items: center;
192+
justify-content: center;
193+
min-height: 100vh;
194+
}
195+
.container {
196+
width: 100%%;
197+
max-width: 1280px;
198+
padding: 20px;
199+
}
200+
.header {
201+
display: flex;
202+
align-items: center;
203+
gap: 12px;
204+
margin-bottom: 16px;
205+
}
206+
.live-badge {
207+
background: #D7331A;
208+
color: white;
209+
font-size: 11px;
210+
font-weight: 700;
211+
padding: 3px 8px;
212+
border-radius: 4px;
213+
text-transform: uppercase;
214+
letter-spacing: 0.5px;
215+
}
216+
h1 {
217+
font-size: 20px;
218+
font-weight: 700;
219+
color: #FCFEFF;
220+
}
221+
video {
222+
width: 100%%;
223+
border-radius: 8px;
224+
background: #16171A;
225+
}
226+
.status {
227+
margin-top: 12px;
228+
font-size: 13px;
229+
color: #AEB3B8;
230+
}
231+
.status.error { color: #D7331A; }
232+
.status.connected { color: #6ABF02; }
233+
</style>
234+
</head>
235+
<body>
236+
<div class="container">
237+
<div class="header">
238+
<span class="live-badge">Live</span>
239+
<h1 id="camera-name"></h1>
240+
</div>
241+
<video id="player" autoplay muted></video>
242+
<div id="status" class="status">Connecting...</div>
243+
</div>
244+
<script>
245+
const streamUrl = %s;
246+
const cameraName = %s;
247+
248+
document.getElementById('camera-name').textContent = cameraName;
249+
document.title = cameraName + ' — Rhombus Live';
250+
251+
const video = document.getElementById('player');
252+
const status = document.getElementById('status');
253+
254+
const authParams = 'x-auth-scheme=federated-token&x-auth-ft=' + streamUrl.split('x-auth-ft=')[1];
255+
256+
const player = dashjs.MediaPlayer().create();
257+
258+
player.updateSettings({
259+
streaming: {
260+
delay: { liveDelay: 2 },
261+
liveCatchup: { enabled: true, mode: 'liveCatchup', playbackRate: { min: -0.5, max: 0.5 } },
262+
buffer: { fastSwitchEnabled: true }
263+
}
264+
});
265+
266+
// Intercept all XHR requests to append auth params to segment URLs
267+
const origOpen = XMLHttpRequest.prototype.open;
268+
XMLHttpRequest.prototype.open = function(method, url) {
269+
if (typeof url === 'string' && url.includes('dash.rhombussystems.com') && !url.includes('x-auth-ft=')) {
270+
url = url + (url.includes('?') ? '&' : '?') + authParams;
271+
}
272+
return origOpen.apply(this, [method, url, ...Array.prototype.slice.call(arguments, 2)]);
273+
};
274+
275+
player.on(dashjs.MediaPlayer.events.PLAYBACK_STARTED, function() {
276+
status.textContent = 'Connected — Live';
277+
status.className = 'status connected';
278+
});
279+
280+
player.on(dashjs.MediaPlayer.events.ERROR, function(e) {
281+
status.textContent = 'Stream error: ' + (e.error?.message || 'unknown');
282+
status.className = 'status error';
283+
});
284+
285+
player.on(dashjs.MediaPlayer.events.PLAYBACK_ERROR, function(e) {
286+
status.textContent = 'Playback error — retrying...';
287+
status.className = 'status error';
288+
setTimeout(function() { player.attachSource(streamUrl); }, 3000);
289+
});
290+
291+
player.initialize(video, streamUrl, true);
292+
video.addEventListener('click', function() {
293+
if (video.muted) { video.muted = false; }
294+
});
295+
</script>
296+
</body>
297+
</html>`, cameraName, streamURLJSON, cameraNameJSON)
298+
299+
if err := os.WriteFile(htmlPath, []byte(html), 0644); err != nil {
300+
return "", err
301+
}
302+
303+
return htmlPath, nil
304+
}
305+
306+
func openInBrowser(url string) {
307+
var cmd *exec.Cmd
308+
switch runtime.GOOS {
309+
case "darwin":
310+
cmd = exec.Command("open", url)
311+
case "linux":
312+
cmd = exec.Command("xdg-open", url)
313+
case "windows":
314+
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
315+
default:
316+
return
317+
}
318+
_ = cmd.Start()
319+
}

0 commit comments

Comments
 (0)