Files
obs-config/webapp/app/Services/ObsWsClient.php
Jakub Zych 059f069ef4 webapp: introduce Laravel app under webapp/ for scene overlays
Replaces the per-scene HTML directories (landing/, loading/, game/,
desktop/, goodbye/, music-box/, music/) with a single Laravel app
serving every overlay over HTTP. Supervisord runs `php artisan serve`
on 127.0.0.1:1118 and the OBS scene JSON now references HTTP routes
instead of file:// URLs.

Highlights:
 - public/css/hud.css consolidates the duplicated HUD chrome,
   scanlines/vignette/flicker, terminal styling, and pulse keyframes
   that were copy-pasted across all seven scenes.
 - Blade partials own hud-strip, crt-overlays, obs-ws-scripts,
   camera-frame, screen-frame; the seven scenes extend a shared
   overlay layout.
 - Artisan commands (`rig:setup`, `rig:telemetry`, `rig:loading`,
   `rig:playlist`, `rig:cmd`) replace the shell scripts that wrote
   per-rig JSON snapshots. TwitchHelix + HardwareSnapshot services
   handle the work the bash + Python helpers used to.
 - ObsWsClient + MusicCommandController kill the 250 ms cmd.js poll
   in the music daemon: POST /cmd/{skip|prev|pause|resume} opens a
   short-lived Pawl WS, authenticates, and broadcasts mpd:cmd.
 - AudioController streams files from the configured music dirs so
   CEF can load tracks under the HTTP origin (Chromium blocks
   HTTP-origin pages from loading file:// media).
 - DataController serves /data/playlist.js (with ETag mtime cache)
   and /cover.jpg (no-store) so the existing overlays' window.__PLAYLIST
   and cover.jpg cache-bust pattern keeps working.

scripts/obs-webapp.supervisord.conf is the supervisord unit; install
to /etc/supervisor.d/obs-webapp.conf.
scripts/rewrite-scene-urls.py is a one-shot tool that rewrites
basic/scenes/Default_Stream_HUD.json from file:// to HTTP URLs.
basic/scenes/Default_Stream_HUD.json.pre-webapp is the rollback
artifact (made with OBS closed; full pre-migration state).

The seven old scene directories, vendor/, and the bash scripts are
still on disk pending visual verification; the next commit will
prune them.
2026-05-21 12:47:10 +02:00

159 lines
6.5 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services;
use Exception;
use Ratchet\Client\Connector as PawlConnector;
use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\MessageInterface;
use React\EventLoop\Loop;
use Throwable;
/**
* Short-lived OBS WebSocket v5 client used to broadcast `mpd:cmd` from the
* Laravel side (POST /cmd/{type} and `php artisan rig:cmd`).
*
* Mirrors the auth + protocol opcodes implemented in public/js/obs-ws-mini.js:
* - op 0 Hello → server sends auth challenge if password set
* - op 1 Identify → client sends auth string + event subscriptions
* - op 2 Identified → identification OK; we can now send Requests
* - op 6 Request → BroadcastCustomEvent with eventData = { _type, ...payload }
* - op 7 Response → request acknowledged
*
* Auth string: base64(SHA256(base64(SHA256(password + salt)) + challenge))
*
* Connect → identify → broadcast → close, all inside a single React event-loop
* tick. Total wall time on localhost is typically 3080 ms.
*/
class ObsWsClient
{
private string $url;
private string $password;
private int $timeoutMs;
public function __construct(?string $url = null, ?string $password = null, int $timeoutMs = 5000)
{
$this->url = $url ?? (string) config('rig.obs_ws.url');
$this->password = $password ?? (string) config('rig.obs_ws.password');
$this->timeoutMs = $timeoutMs;
}
/**
* Broadcast a CustomEvent through OBS WS to every connected client.
* `_type` is added to eventData so the mini client's `onCustom(typeFilter)` works.
*/
public function broadcast(string $type, array $payload = []): void
{
$eventData = array_merge(['_type' => $type], $payload);
$this->sendRequest('BroadcastCustomEvent', ['eventData' => $eventData]);
}
public function sendRequest(string $requestType, array $requestData = []): array
{
$loop = Loop::get();
$connector = new PawlConnector($loop);
$result = null;
$error = null;
$identified = false;
$requestId = 'php-' . bin2hex(random_bytes(6));
$promise = $connector($this->url);
$promise->then(function (WebSocket $conn) use (&$result, &$error, &$identified, $requestId, $requestType, $requestData) {
$conn->on('message', function (MessageInterface $msg) use ($conn, &$result, &$error, &$identified, $requestId, $requestType, $requestData) {
$payload = json_decode((string) $msg, true);
if (!is_array($payload) || !isset($payload['op'])) return;
try {
switch ($payload['op']) {
case 0: // Hello
$auth = null;
if (isset($payload['d']['authentication'])) {
if ($this->password === '') {
$error = new Exception('OBS WS server requires password but none configured');
$conn->close();
return;
}
$auth = $this->authString(
$payload['d']['authentication']['salt'],
$payload['d']['authentication']['challenge']
);
}
$identify = [
'op' => 1,
'd' => [
'rpcVersion' => 1,
'authentication' => $auth,
'eventSubscriptions' => 0,
],
];
// null auth would serialize as JSON null; OBS WS expects the key absent when no auth.
if ($auth === null) unset($identify['d']['authentication']);
$conn->send(json_encode($identify));
break;
case 2: // Identified
$identified = true;
$conn->send(json_encode([
'op' => 6,
'd' => [
'requestType' => $requestType,
'requestId' => $requestId,
'requestData' => (object) $requestData,
],
]));
break;
case 7: // RequestResponse
if (($payload['d']['requestId'] ?? null) !== $requestId) return;
$status = $payload['d']['requestStatus'] ?? [];
if (!empty($status['result'])) {
$result = $payload['d']['responseData'] ?? [];
} else {
$error = new Exception(
'OBS WS request failed: ' . ($status['comment'] ?? 'unknown')
);
}
$conn->close();
break;
}
} catch (Throwable $e) {
$error = $e;
$conn->close();
}
});
$conn->on('close', function () {
Loop::stop();
});
$conn->on('error', function ($e) use (&$error) {
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
Loop::stop();
});
}, function ($e) use (&$error) {
$error = $e instanceof Throwable ? $e : new Exception((string) $e);
Loop::stop();
});
// Hard timeout guard — Loop::stop fires no matter what.
$timer = $loop->addTimer($this->timeoutMs / 1000, function () use (&$error) {
$error = $error ?: new Exception('OBS WS request timed out');
Loop::stop();
});
$loop->run();
$loop->cancelTimer($timer);
if ($error) throw $error;
if (!$identified) throw new Exception('OBS WS never reached Identified');
return $result ?? [];
}
private function authString(string $salt, string $challenge): string
{
$secret = base64_encode(hash('sha256', $this->password . $salt, true));
return base64_encode(hash('sha256', $secret . $challenge, true));
}
}