streampal-connect 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/bin/streampal-connect.js +345 -0
- package/package.json +23 -0
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# streampal-connect
|
|
2
|
+
|
|
3
|
+
The one-command agent that links your OBS to your [StreamPal](https://streampal.gg)
|
|
4
|
+
dashboard and phone stream deck.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx streampal-connect <your-pair-code>
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Find your pair code on your dashboard at **streampal.gg → OBS**.
|
|
11
|
+
|
|
12
|
+
It:
|
|
13
|
+
|
|
14
|
+
- **auto-detects OBS** on this PC (`ws://127.0.0.1:4455`) — asks for the OBS
|
|
15
|
+
WebSocket password once, then remembers it,
|
|
16
|
+
- connects **outbound** to StreamPal's relay, so your home network needs no
|
|
17
|
+
port-forwarding or firewall changes,
|
|
18
|
+
- bridges the two: buttons on your dashboard / phone run against OBS here.
|
|
19
|
+
|
|
20
|
+
Leave the window open while you stream. `Ctrl+C` to stop. Next time it's just
|
|
21
|
+
`npx streampal-connect` — your code and password are saved to
|
|
22
|
+
`~/.streampal/connect.json` (that file never leaves your machine).
|
|
23
|
+
|
|
24
|
+
## Options
|
|
25
|
+
|
|
26
|
+
| Flag | Meaning |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `--relay <wss://…>` | Use a different relay (default: StreamPal's public relay) |
|
|
29
|
+
| `--obs-url <ws://…>` | OBS WebSocket URL (default `ws://127.0.0.1:4455`) |
|
|
30
|
+
| `--obs-password <pw>` | Pass the OBS password non-interactively |
|
|
31
|
+
| `--reset` | Forget saved settings and set up fresh |
|
|
32
|
+
| `--help` | Show help |
|
|
33
|
+
|
|
34
|
+
Environment variables `STREAMPAL_PAIR_CODE`, `STREAMPAL_RELAY_URL`,
|
|
35
|
+
`OBS_PASSWORD` are also honoured.
|
|
36
|
+
|
|
37
|
+
## First-time OBS setup
|
|
38
|
+
|
|
39
|
+
In OBS: **Tools → WebSocket Server Settings → Enable WebSocket server**. Note
|
|
40
|
+
the password (Show Connect Info). That's the only thing you paste besides your
|
|
41
|
+
pair code.
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// streampal-connect
|
|
3
|
+
// -----------------
|
|
4
|
+
// The one-command agent. Run it on the PC where OBS lives:
|
|
5
|
+
//
|
|
6
|
+
// npx streampal-connect <your-pair-code>
|
|
7
|
+
//
|
|
8
|
+
// It auto-detects OBS on localhost, connects OUTBOUND to StreamPal's relay
|
|
9
|
+
// (so your home network needs no port-forwarding), and bridges the two. Press
|
|
10
|
+
// a button on your dashboard / phone stream deck and it runs against OBS here.
|
|
11
|
+
//
|
|
12
|
+
// Nothing is stored on StreamPal's servers except your account's pair code.
|
|
13
|
+
// Your OBS password stays on this machine (~/.streampal/connect.json).
|
|
14
|
+
|
|
15
|
+
"use strict";
|
|
16
|
+
|
|
17
|
+
const fs = require("fs");
|
|
18
|
+
const os = require("os");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
const readline = require("readline");
|
|
21
|
+
const WebSocket = require("ws");
|
|
22
|
+
const { OBSWebSocket } = require("obs-websocket-js");
|
|
23
|
+
|
|
24
|
+
// -------------------------------------------------------------- tiny ui
|
|
25
|
+
|
|
26
|
+
const C = {
|
|
27
|
+
reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
|
|
28
|
+
green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m",
|
|
29
|
+
cyan: "\x1b[36m", magenta: "\x1b[35m",
|
|
30
|
+
};
|
|
31
|
+
const ok = (m) => console.log(`${C.green}✓${C.reset} ${m}`);
|
|
32
|
+
const warn = (m) => console.log(`${C.yellow}!${C.reset} ${m}`);
|
|
33
|
+
const err = (m) => console.log(`${C.red}✗${C.reset} ${m}`);
|
|
34
|
+
const info = (m) => console.log(`${C.dim}${m}${C.reset}`);
|
|
35
|
+
|
|
36
|
+
function banner() {
|
|
37
|
+
console.log(`${C.magenta}${C.bold}
|
|
38
|
+
┌────────────────────────────────────┐
|
|
39
|
+
│ StreamPal Connect │
|
|
40
|
+
└────────────────────────────────────┘${C.reset}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// -------------------------------------------------------------- args + config
|
|
44
|
+
|
|
45
|
+
const argv = process.argv.slice(2);
|
|
46
|
+
function flag(name) {
|
|
47
|
+
const i = argv.indexOf(`--${name}`);
|
|
48
|
+
return i >= 0 ? (argv[i + 1] || "") : undefined;
|
|
49
|
+
}
|
|
50
|
+
const hasFlag = (name) => argv.includes(`--${name}`);
|
|
51
|
+
|
|
52
|
+
const positional = argv.filter((a) => !a.startsWith("--"));
|
|
53
|
+
// crude: skip values that belong to a flag
|
|
54
|
+
const flagValues = new Set();
|
|
55
|
+
for (let i = 0; i < argv.length; i++) {
|
|
56
|
+
if (argv[i].startsWith("--") && argv[i + 1] && !argv[i + 1].startsWith("--")) {
|
|
57
|
+
flagValues.add(argv[i + 1]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const positionalPairCode = positional.find((p) => !flagValues.has(p));
|
|
61
|
+
|
|
62
|
+
const CONFIG_DIR = path.join(os.homedir(), ".streampal");
|
|
63
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, "connect.json");
|
|
64
|
+
|
|
65
|
+
function loadConfig() {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(fs.readFileSync(CONFIG_PATH, "utf8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function saveConfig(cfg) {
|
|
73
|
+
try {
|
|
74
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
75
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
76
|
+
} catch (e) {
|
|
77
|
+
warn(`couldn't save settings: ${e.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (hasFlag("reset")) {
|
|
82
|
+
try { fs.unlinkSync(CONFIG_PATH); } catch {}
|
|
83
|
+
ok("cleared saved settings");
|
|
84
|
+
}
|
|
85
|
+
if (hasFlag("help") || hasFlag("h")) {
|
|
86
|
+
banner();
|
|
87
|
+
console.log(`
|
|
88
|
+
Usage: npx streampal-connect <pair-code> [options]
|
|
89
|
+
|
|
90
|
+
Options:
|
|
91
|
+
--relay <wss://...> Relay URL (default StreamPal's public relay)
|
|
92
|
+
--obs-url <ws://...> OBS WebSocket URL (default ws://127.0.0.1:4455)
|
|
93
|
+
--obs-password <pw> OBS WebSocket password (else you'll be asked once)
|
|
94
|
+
--reset Forget saved settings and set up fresh
|
|
95
|
+
--help Show this help
|
|
96
|
+
|
|
97
|
+
Your pair code is on your dashboard at streampal.gg → OBS.
|
|
98
|
+
`);
|
|
99
|
+
process.exit(0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const cfg = loadConfig();
|
|
103
|
+
|
|
104
|
+
const DEFAULT_RELAY = process.env.STREAMPAL_RELAY_URL || "wss://relay.streampal.gg";
|
|
105
|
+
const RELAY_URL = (flag("relay") || cfg.relayUrl || DEFAULT_RELAY).replace(/\/$/, "");
|
|
106
|
+
const OBS_URL = flag("obs-url") || cfg.obsUrl || "ws://127.0.0.1:4455";
|
|
107
|
+
let PAIR_CODE = positionalPairCode || process.env.STREAMPAL_PAIR_CODE || cfg.pairCode || "";
|
|
108
|
+
let OBS_PASSWORD =
|
|
109
|
+
flag("obs-password") !== undefined
|
|
110
|
+
? flag("obs-password")
|
|
111
|
+
: process.env.OBS_PASSWORD !== undefined
|
|
112
|
+
? process.env.OBS_PASSWORD
|
|
113
|
+
: cfg.obsPassword;
|
|
114
|
+
|
|
115
|
+
// -------------------------------------------------------------- prompts
|
|
116
|
+
|
|
117
|
+
function ask(question, { hidden = false } = {}) {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
120
|
+
if (hidden) {
|
|
121
|
+
// Mask keystrokes for the OBS password.
|
|
122
|
+
const onData = (char) => {
|
|
123
|
+
char = char + "";
|
|
124
|
+
if (char === "\n" || char === "\r" || char === "") {
|
|
125
|
+
process.stdin.removeListener("data", onData);
|
|
126
|
+
} else {
|
|
127
|
+
readline.clearLine(process.stdout, 0);
|
|
128
|
+
readline.cursorTo(process.stdout, 0);
|
|
129
|
+
process.stdout.write(question + "*".repeat(rl.line.length));
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
process.stdin.on("data", onData);
|
|
133
|
+
}
|
|
134
|
+
rl.question(question, (answer) => {
|
|
135
|
+
rl.close();
|
|
136
|
+
if (hidden) process.stdout.write("\n");
|
|
137
|
+
resolve(answer.trim());
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// -------------------------------------------------------------- OBS
|
|
143
|
+
|
|
144
|
+
const obs = new OBSWebSocket();
|
|
145
|
+
let obsConnected = false;
|
|
146
|
+
let relay = null;
|
|
147
|
+
let relayTimer = null;
|
|
148
|
+
|
|
149
|
+
async function tryObs(url, password) {
|
|
150
|
+
const client = new OBSWebSocket();
|
|
151
|
+
try {
|
|
152
|
+
await client.connect(url, password || undefined);
|
|
153
|
+
await client.disconnect();
|
|
154
|
+
return { ok: true };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
const msg = (e && e.message) || String(e);
|
|
157
|
+
// obs-websocket-js: code 4009 == auth failed, 4008 == auth required
|
|
158
|
+
const authIssue = /authentication|4009|4008|password/i.test(msg);
|
|
159
|
+
return { ok: false, authIssue, msg };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function ensureObsCredentials() {
|
|
164
|
+
info(`Looking for OBS at ${OBS_URL} …`);
|
|
165
|
+
let probe = await tryObs(OBS_URL, OBS_PASSWORD);
|
|
166
|
+
if (probe.ok) {
|
|
167
|
+
ok("found OBS");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!probe.authIssue) {
|
|
171
|
+
warn("OBS isn't reachable yet — make sure OBS is open and its WebSocket");
|
|
172
|
+
warn("server is enabled (Tools → WebSocket Server Settings). I'll keep trying.");
|
|
173
|
+
return; // connectOBS() will retry in the loop
|
|
174
|
+
}
|
|
175
|
+
// Auth problem — ask for the password (once) and re-probe.
|
|
176
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
177
|
+
warn("OBS needs its WebSocket password.");
|
|
178
|
+
info("Find it in OBS → Tools → WebSocket Server Settings → Show Connect Info.");
|
|
179
|
+
const pw = await ask(`${C.cyan}OBS password: ${C.reset}`, { hidden: true });
|
|
180
|
+
OBS_PASSWORD = pw;
|
|
181
|
+
probe = await tryObs(OBS_URL, OBS_PASSWORD);
|
|
182
|
+
if (probe.ok) {
|
|
183
|
+
ok("password accepted");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
err("that password didn't work — try again");
|
|
187
|
+
}
|
|
188
|
+
err("couldn't authenticate with OBS. Run again with the right password.");
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function connectOBS() {
|
|
193
|
+
try {
|
|
194
|
+
await obs.connect(OBS_URL, OBS_PASSWORD || undefined);
|
|
195
|
+
obsConnected = true;
|
|
196
|
+
ok(`OBS connected (${OBS_URL})`);
|
|
197
|
+
pushStatus();
|
|
198
|
+
} catch (e) {
|
|
199
|
+
obsConnected = false;
|
|
200
|
+
info(`waiting for OBS … (${e.message})`);
|
|
201
|
+
setTimeout(connectOBS, 5000);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
obs.on("ConnectionClosed", () => {
|
|
206
|
+
if (!obsConnected) return;
|
|
207
|
+
obsConnected = false;
|
|
208
|
+
warn("OBS closed — reconnecting");
|
|
209
|
+
pushStatus();
|
|
210
|
+
setTimeout(connectOBS, 5000);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// keep dashboard buttons in sync with changes made directly in OBS
|
|
214
|
+
obs.on("StreamStateChanged", (d) => toRelay({ type: "event", event: "stream", active: d.outputActive }));
|
|
215
|
+
obs.on("RecordStateChanged", (d) => toRelay({ type: "event", event: "record", active: d.outputActive }));
|
|
216
|
+
obs.on("CurrentProgramSceneChanged", (d) => toRelay({ type: "event", event: "scene", scene: d.sceneName }));
|
|
217
|
+
obs.on("InputMuteStateChanged", (d) => toRelay({ type: "event", event: "mute", inputName: d.inputName, muted: d.inputMuted }));
|
|
218
|
+
obs.on("VirtualcamStateChanged", (d) => toRelay({ type: "event", event: "virtualcam", active: d.outputActive }));
|
|
219
|
+
obs.on("ReplayBufferStateChanged", (d) => toRelay({ type: "event", event: "replay", active: d.outputActive }));
|
|
220
|
+
|
|
221
|
+
// -------------------------------------------------------------- relay
|
|
222
|
+
|
|
223
|
+
let relayReady = false;
|
|
224
|
+
|
|
225
|
+
function connectRelay() {
|
|
226
|
+
const url = `${RELAY_URL}/?role=agent&code=${encodeURIComponent(PAIR_CODE)}`;
|
|
227
|
+
info(`Connecting to relay …`);
|
|
228
|
+
relay = new WebSocket(url);
|
|
229
|
+
|
|
230
|
+
relay.on("open", () => {
|
|
231
|
+
relayReady = true;
|
|
232
|
+
ok("relay connected — your dashboard can now control OBS");
|
|
233
|
+
console.log(`${C.dim} Leave this window open while you stream. Ctrl+C to stop.${C.reset}`);
|
|
234
|
+
pushStatus();
|
|
235
|
+
});
|
|
236
|
+
relay.on("message", (raw) => {
|
|
237
|
+
let msg;
|
|
238
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
239
|
+
if (msg.type === "command") handleCommand(msg);
|
|
240
|
+
});
|
|
241
|
+
relay.on("close", (code) => {
|
|
242
|
+
if (relayReady) warn("relay disconnected — reconnecting");
|
|
243
|
+
else if (code === 1008 || code === 4401) {
|
|
244
|
+
err("relay rejected the pair code. Copy it again from streampal.gg → OBS.");
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
relayReady = false;
|
|
248
|
+
scheduleRelayReconnect();
|
|
249
|
+
});
|
|
250
|
+
relay.on("error", (e) => {
|
|
251
|
+
if (!relayReady) err(`relay error: ${e.message}`);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function scheduleRelayReconnect() {
|
|
255
|
+
if (relayTimer) return;
|
|
256
|
+
relayTimer = setTimeout(() => { relayTimer = null; connectRelay(); }, 3000);
|
|
257
|
+
}
|
|
258
|
+
function toRelay(obj) {
|
|
259
|
+
if (relay && relay.readyState === WebSocket.OPEN) relay.send(JSON.stringify(obj));
|
|
260
|
+
}
|
|
261
|
+
function pushStatus() {
|
|
262
|
+
toRelay({ type: "obs-status", connected: obsConnected });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// -------------------------------------------------------------- commands
|
|
266
|
+
|
|
267
|
+
async function handleCommand(msg) {
|
|
268
|
+
const { id, action, params } = msg;
|
|
269
|
+
try {
|
|
270
|
+
let data;
|
|
271
|
+
if (action === "snapshot") {
|
|
272
|
+
const sceneList = await obs.call("GetSceneList");
|
|
273
|
+
let streaming = false, recording = false, virtualCam = false, replayBuffer = false;
|
|
274
|
+
try { streaming = (await obs.call("GetStreamStatus")).outputActive; } catch {}
|
|
275
|
+
try { recording = (await obs.call("GetRecordStatus")).outputActive; } catch {}
|
|
276
|
+
try { virtualCam = (await obs.call("GetVirtualCamStatus")).outputActive; } catch {}
|
|
277
|
+
try { replayBuffer = (await obs.call("GetReplayBufferStatus")).outputActive; } catch {}
|
|
278
|
+
const audioInputs = [];
|
|
279
|
+
try {
|
|
280
|
+
const { inputs } = await obs.call("GetInputList");
|
|
281
|
+
for (const input of inputs) {
|
|
282
|
+
try {
|
|
283
|
+
const { inputMuted } = await obs.call("GetInputMute", { inputName: input.inputName });
|
|
284
|
+
audioInputs.push({ name: input.inputName, muted: inputMuted });
|
|
285
|
+
} catch { /* not an audio input */ }
|
|
286
|
+
}
|
|
287
|
+
} catch {}
|
|
288
|
+
data = {
|
|
289
|
+
scenes: sceneList.scenes.map((s) => s.sceneName).reverse(),
|
|
290
|
+
currentScene: sceneList.currentProgramSceneName,
|
|
291
|
+
streaming, recording, virtualCam, replayBuffer, audioInputs,
|
|
292
|
+
};
|
|
293
|
+
} else {
|
|
294
|
+
data = await obs.call(action, params || {});
|
|
295
|
+
}
|
|
296
|
+
toRelay({ type: "result", id, ok: true, data });
|
|
297
|
+
} catch (e) {
|
|
298
|
+
err(`"${action}" failed: ${e.message}`);
|
|
299
|
+
toRelay({ type: "result", id, ok: false, error: e.message });
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// -------------------------------------------------------------- boot
|
|
304
|
+
|
|
305
|
+
async function main() {
|
|
306
|
+
banner();
|
|
307
|
+
|
|
308
|
+
if (!PAIR_CODE) {
|
|
309
|
+
info("Find your pair code on your dashboard: streampal.gg → OBS.");
|
|
310
|
+
PAIR_CODE = await ask(`${C.cyan}Pair code: ${C.reset}`);
|
|
311
|
+
if (!PAIR_CODE) {
|
|
312
|
+
err("a pair code is required.");
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await ensureObsCredentials();
|
|
318
|
+
|
|
319
|
+
// Persist what we learned so next time it's truly one command.
|
|
320
|
+
saveConfig({
|
|
321
|
+
pairCode: PAIR_CODE,
|
|
322
|
+
obsUrl: OBS_URL,
|
|
323
|
+
obsPassword: OBS_PASSWORD || "",
|
|
324
|
+
relayUrl: RELAY_URL === DEFAULT_RELAY ? undefined : RELAY_URL,
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
console.log();
|
|
328
|
+
info(`relay : ${RELAY_URL}`);
|
|
329
|
+
info(`obs : ${OBS_URL}`);
|
|
330
|
+
info(`code : ${PAIR_CODE.slice(0, 6)}…`);
|
|
331
|
+
console.log();
|
|
332
|
+
|
|
333
|
+
connectOBS();
|
|
334
|
+
connectRelay();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
process.on("SIGINT", () => {
|
|
338
|
+
console.log(`\n${C.dim}Stopping StreamPal Connect. Bye!${C.reset}`);
|
|
339
|
+
process.exit(0);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
main().catch((e) => {
|
|
343
|
+
err(e.message);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "streampal-connect",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Link your OBS to your StreamPal dashboard. Runs on your streaming PC and connects outbound to StreamPal's relay — no port-forwarding, no config.",
|
|
5
|
+
"keywords": ["streampal", "obs", "obs-websocket", "streaming", "stream-deck"],
|
|
6
|
+
"homepage": "https://streampal.gg/download",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "commonjs",
|
|
9
|
+
"bin": {
|
|
10
|
+
"streampal-connect": "bin/streampal-connect.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"obs-websocket-js": "^5.0.6",
|
|
21
|
+
"ws": "^8.18.0"
|
|
22
|
+
}
|
|
23
|
+
}
|