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.
This commit is contained in:
30
webapp/app/Console/Commands/RigCmdCommand.php
Normal file
30
webapp/app/Console/Commands/RigCmdCommand.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ObsWsClient;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class RigCmdCommand extends Command
|
||||
{
|
||||
protected $signature = 'rig:cmd {type : skip|prev|pause|resume}';
|
||||
protected $description = 'Broadcast a music daemon command over OBS WS (CLI mirror of POST /cmd/{type})';
|
||||
|
||||
public function handle(ObsWsClient $ws): int
|
||||
{
|
||||
$type = strtolower((string) $this->argument('type'));
|
||||
if (!in_array($type, ['skip', 'prev', 'pause', 'resume'], true)) {
|
||||
$this->error('type must be one of: skip, prev, pause, resume');
|
||||
return self::FAILURE;
|
||||
}
|
||||
try {
|
||||
$ws->broadcast('mpd:cmd', ['type' => $type]);
|
||||
} catch (Throwable $e) {
|
||||
$this->error($e->getMessage());
|
||||
return self::FAILURE;
|
||||
}
|
||||
$this->info(" → {$type}");
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
101
webapp/app/Console/Commands/RigLoadingCommand.php
Normal file
101
webapp/app/Console/Commands/RigLoadingCommand.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\TwitchHelix;
|
||||
use App\Support\RigData;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class RigLoadingCommand extends Command
|
||||
{
|
||||
protected $signature = 'rig:loading {--no-twitch : Skip pushing the channel update to Twitch}';
|
||||
protected $description = 'Interactive Project Loading manifest builder';
|
||||
|
||||
public function handle(RigData $rig, TwitchHelix $twitch): int
|
||||
{
|
||||
$prev = $rig->loading();
|
||||
|
||||
$this->line('── Project Loading manifest ──');
|
||||
$this->line(' (press Enter to keep [defaults])');
|
||||
$this->line('');
|
||||
|
||||
$game = null;
|
||||
$gameId = null;
|
||||
$defaultGame = (string) ($prev['game'] ?? '');
|
||||
while (true) {
|
||||
$query = (string) $this->ask('Game (search)', $defaultGame ?: null);
|
||||
if ($query === '') {
|
||||
$this->error(' ✗ Game is required');
|
||||
continue;
|
||||
}
|
||||
if ($twitch->enabled()) {
|
||||
$cachedId = (string) ($prev['gameId'] ?? '');
|
||||
if ($query === $defaultGame && preg_match('/^\d+$/', $cachedId)) {
|
||||
$game = $defaultGame;
|
||||
$gameId = $cachedId;
|
||||
$this->line(" ↻ reusing cached: {$game} (id={$gameId})");
|
||||
break;
|
||||
}
|
||||
try {
|
||||
$hit = $twitch->searchCategory($query);
|
||||
} catch (Throwable $e) {
|
||||
$this->warn(' ! Twitch search failed: ' . $e->getMessage());
|
||||
if ($this->confirm('Use the raw input without resolving an id?', true)) {
|
||||
$game = $query;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ($hit === null) {
|
||||
$this->warn(' ! no Twitch matches for "' . $query . '"');
|
||||
continue;
|
||||
}
|
||||
$game = $hit['name'];
|
||||
$gameId = $hit['id'];
|
||||
$this->line(" ↳ matched: {$game} (id={$gameId})");
|
||||
break;
|
||||
}
|
||||
$game = $query;
|
||||
break;
|
||||
}
|
||||
|
||||
$subtitle = (string) $this->ask('Subtitle (mode / episode / note)', $prev['subtitle'] ?? null);
|
||||
$countdown = (int) $this->ask('Countdown (minutes)', (string) ($prev['countdownMin'] ?? 5));
|
||||
$camera = $this->confirm('Camera', (bool) ($prev['camera'] ?? true));
|
||||
$microphone = $this->confirm('Microphone', (bool) ($prev['microphone'] ?? true));
|
||||
|
||||
$data = [
|
||||
'compiledAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'game' => $game,
|
||||
'gameId' => $gameId,
|
||||
'subtitle' => $subtitle ?: null,
|
||||
'countdownMin' => $countdown,
|
||||
'camera' => $camera,
|
||||
'microphone' => $microphone,
|
||||
];
|
||||
$path = rtrim(config('rig.storage_data'), '/') . '/loading.json';
|
||||
@mkdir(dirname($path), 0775, true);
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
|
||||
);
|
||||
$this->info('');
|
||||
$this->info(" ✓ wrote {$path}");
|
||||
|
||||
// Push to Twitch — soft-fail so a network blip doesn't block the local manifest.
|
||||
if (!$this->option('no-twitch') && $twitch->enabled() && $gameId) {
|
||||
$title = $subtitle ?: $game;
|
||||
try {
|
||||
$twitch->setChannel($title, $gameId);
|
||||
$this->info(" ✓ Twitch channel updated → \"{$title}\" / category {$game}");
|
||||
} catch (Throwable $e) {
|
||||
$this->warn(' ! Twitch sync failed (manifest still saved): ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
$this->line(' → refresh the Project Loading browser source in OBS.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
125
webapp/app/Console/Commands/RigPlaylistCommand.php
Normal file
125
webapp/app/Console/Commands/RigPlaylistCommand.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
use Symfony\Component\Process\Process;
|
||||
use Throwable;
|
||||
|
||||
class RigPlaylistCommand extends Command
|
||||
{
|
||||
protected $signature = 'rig:playlist';
|
||||
protected $description = 'Index audio under the configured music roots → storage/data/playlist.json';
|
||||
|
||||
private const EXTS = ['m4a', 'mp3', 'opus', 'ogg', 'flac', 'webm', 'aac', 'wav'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$roots = array_values(array_filter((array) config('rig.music_dirs')));
|
||||
if (empty($roots)) {
|
||||
$this->warn(' ! no music dirs configured (set MUSIC_DIR_1 in .env)');
|
||||
}
|
||||
|
||||
$ffprobe = (string) config('rig.ffprobe', '/usr/bin/ffprobe');
|
||||
if (!is_executable($ffprobe)) {
|
||||
$this->error(" ✗ ffprobe not found at {$ffprobe} (install ffmpeg or set FFPROBE_BIN)");
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->line('── Indexing audio roots ──');
|
||||
foreach ($roots as $r) {
|
||||
$this->line(is_dir($r) ? " + {$r}" : " ! {$r} (missing — skipped)");
|
||||
}
|
||||
|
||||
$tracks = [];
|
||||
foreach ($roots as $root) {
|
||||
if (!is_dir($root)) continue;
|
||||
$finder = (new Finder())
|
||||
->files()
|
||||
->in($root)
|
||||
->name('/\.(' . implode('|', self::EXTS) . ')$/i')
|
||||
->ignoreUnreadableDirs()
|
||||
->sortByName();
|
||||
|
||||
foreach ($finder as $file) {
|
||||
$name = $file->getFilename();
|
||||
$base = $file->getFilenameWithoutExtension();
|
||||
// Skip yt-dlp intermediates (e.g. foo.f140.m4a, foo.temp.opus).
|
||||
if (preg_match('/\.(?:f\d+|temp|part)$/i', $base)) continue;
|
||||
|
||||
$path = $file->getRealPath();
|
||||
$meta = $this->probe($ffprobe, $path);
|
||||
$tracks[] = [
|
||||
'title' => $this->cleanTitle($base),
|
||||
'file' => 'file://' . $this->urlEncodePath($path),
|
||||
'durationSec' => isset($meta['durationSec']) ? (int) $meta['durationSec'] : null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$doc = [
|
||||
'syncedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'sourceDirs' => $roots,
|
||||
'trackCount' => count($tracks),
|
||||
'tracks' => $tracks,
|
||||
];
|
||||
$path = rtrim(config('rig.storage_data'), '/') . '/playlist.json';
|
||||
@mkdir(dirname($path), 0775, true);
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($doc, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
|
||||
$this->line('');
|
||||
$this->info(" ✓ wrote {$path} ({$doc['trackCount']} tracks)");
|
||||
$this->line(' → refresh the Music Daemon browser source in OBS to reload the queue.');
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function probe(string $ffprobe, string $path): array
|
||||
{
|
||||
try {
|
||||
$proc = new Process([
|
||||
$ffprobe, '-v', 'error',
|
||||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||||
'-of', 'json',
|
||||
$path,
|
||||
]);
|
||||
$proc->setTimeout(10);
|
||||
$proc->run();
|
||||
if (!$proc->isSuccessful()) return [];
|
||||
$json = json_decode($proc->getOutput(), true) ?: [];
|
||||
$fmt = $json['format'] ?? [];
|
||||
return [
|
||||
'durationSec' => isset($fmt['duration']) ? (float) $fmt['duration'] : null,
|
||||
];
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors scripts/playlist.sh's clean_title():
|
||||
* - drop "| ..." (or fullwidth |) tails (genre/label noise)
|
||||
* - strip trailing YouTube IDs in brackets like "[-XxZTgMWKV0]"
|
||||
* - strip "[NCS Release]" / "(NCS10 Release)" suffixes
|
||||
*/
|
||||
private function cleanTitle(string $t): string
|
||||
{
|
||||
$t = preg_replace('/\s*[||].*$/u', '', $t) ?? $t;
|
||||
$t = preg_replace('/\s*\[[A-Za-z0-9_-]{11}\]\s*$/', '', $t) ?? $t;
|
||||
$t = preg_replace(
|
||||
'/\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$/i',
|
||||
'',
|
||||
$t
|
||||
) ?? $t;
|
||||
return trim($t);
|
||||
}
|
||||
|
||||
private function urlEncodePath(string $path): string
|
||||
{
|
||||
// Same as Python's urllib.parse.quote(..., safe='/') — keep separators, encode the rest.
|
||||
return implode('/', array_map('rawurlencode', explode('/', $path)));
|
||||
}
|
||||
}
|
||||
84
webapp/app/Console/Commands/RigSetupCommand.php
Normal file
84
webapp/app/Console/Commands/RigSetupCommand.php
Normal file
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RigSetupCommand extends Command
|
||||
{
|
||||
protected $signature = 'rig:setup';
|
||||
protected $description = 'Generate public/js/obs-config.js and OBS_WS_* env vars from plugin_config/obs-websocket/config.json';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$src = (string) config('rig.obs_ws_config_json');
|
||||
if (!is_file($src)) {
|
||||
$this->error(" ✗ {$src} not found.");
|
||||
$this->line(' Open OBS once with obs-websocket loaded so it generates the config.');
|
||||
return self::FAILURE;
|
||||
}
|
||||
$cfg = json_decode((string) file_get_contents($src), true);
|
||||
if (!is_array($cfg)) {
|
||||
$this->error(" ✗ {$src} did not parse as JSON.");
|
||||
return self::FAILURE;
|
||||
}
|
||||
$port = (int) ($cfg['server_port'] ?? 4455);
|
||||
$pass = (string) ($cfg['server_password'] ?? '');
|
||||
$url = "ws://localhost:{$port}";
|
||||
|
||||
$out = (string) config('rig.obs_config_js');
|
||||
@mkdir(dirname($out), 0775, true);
|
||||
$js = <<<JS
|
||||
// Auto-generated by `php artisan rig:setup`. Reflects the current contents of
|
||||
// plugin_config/obs-websocket/config.json. Re-run if you rotate the password.
|
||||
window.__OBSWS = {
|
||||
url: {$this->jsString($url)},
|
||||
password: {$this->jsString($pass)},
|
||||
};
|
||||
|
||||
JS;
|
||||
file_put_contents($out, $js);
|
||||
$this->info(" ✓ wrote {$out}");
|
||||
|
||||
// Mirror into .env so MusicCommandController / ObsWsClient can authenticate.
|
||||
$this->writeEnv('OBS_WS_URL', $url);
|
||||
$this->writeEnv('OBS_WS_PASSWORD', $pass);
|
||||
$this->info(' ✓ updated .env OBS_WS_URL / OBS_WS_PASSWORD');
|
||||
|
||||
$this->line('');
|
||||
$this->line(" url = {$url}");
|
||||
$this->line(' password = ' . ($pass === '' ? '(none)' : '(set, ' . strlen($pass) . ' chars)'));
|
||||
$this->line('');
|
||||
$this->line(' → if obs-webapp is running under supervisord, restart it so the new env takes effect:');
|
||||
$this->line(' sudo supervisorctl restart obs-webapp');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function jsString(string $s): string
|
||||
{
|
||||
return json_encode($s, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function writeEnv(string $key, string $value): void
|
||||
{
|
||||
$env = base_path('.env');
|
||||
$raw = is_file($env) ? file_get_contents($env) : '';
|
||||
$line = $key . '=' . $this->escapeEnvValue($value);
|
||||
if (preg_match("/^{$key}=.*$/m", $raw)) {
|
||||
$raw = preg_replace("/^{$key}=.*$/m", $line, $raw);
|
||||
} else {
|
||||
$raw = rtrim($raw, "\n") . "\n" . $line . "\n";
|
||||
}
|
||||
file_put_contents($env, $raw);
|
||||
}
|
||||
|
||||
private function escapeEnvValue(string $v): string
|
||||
{
|
||||
if ($v === '') return '';
|
||||
if (preg_match('/[\s"#=]/', $v)) {
|
||||
return '"' . str_replace('"', '\"', $v) . '"';
|
||||
}
|
||||
return $v;
|
||||
}
|
||||
}
|
||||
42
webapp/app/Console/Commands/RigTelemetryCommand.php
Normal file
42
webapp/app/Console/Commands/RigTelemetryCommand.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\HardwareSnapshot;
|
||||
use App\Support\RigData;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class RigTelemetryCommand extends Command
|
||||
{
|
||||
protected $signature = 'rig:telemetry {--collect : Re-read hardware + OBS profile (default: only refresh if file is missing)}';
|
||||
protected $description = 'Capture rig hardware + OBS profile snapshot → storage/data/telemetry.json';
|
||||
|
||||
public function handle(HardwareSnapshot $snap, RigData $rig): int
|
||||
{
|
||||
$path = rtrim(config('rig.storage_data'), '/') . '/telemetry.json';
|
||||
|
||||
if ($this->option('collect') || !is_file($path)) {
|
||||
$data = $snap->collect();
|
||||
@mkdir(dirname($path), 0775, true);
|
||||
file_put_contents(
|
||||
$path,
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"
|
||||
);
|
||||
$this->info(" ✓ wrote {$path}");
|
||||
} else {
|
||||
$this->line(" · {$path} already exists — use --collect to re-read hardware.");
|
||||
}
|
||||
|
||||
$current = $rig->telemetry();
|
||||
$this->line('');
|
||||
$this->line(" rig = " . ($current['rig'] ?? '?'));
|
||||
$this->line(" cpu = " . ($current['cpu']['model'] ?? '?'));
|
||||
if (!empty($current['gpu']['name'])) {
|
||||
$this->line(" gpu = " . $current['gpu']['name']);
|
||||
}
|
||||
if (!empty($current['obs']['output']['w'])) {
|
||||
$this->line(" output = {$current['obs']['output']['w']}×{$current['obs']['output']['h']} @ {$current['obs']['fps']} fps");
|
||||
}
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
71
webapp/app/Http/Controllers/AudioController.php
Normal file
71
webapp/app/Http/Controllers/AudioController.php
Normal file
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* Streams audio files from the configured music_dirs via HTTP so the
|
||||
* music-daemon page (served on http://127.0.0.1:8000/) can load them.
|
||||
*
|
||||
* Chromium blocks HTTP-origin pages from loading `file://` media as a
|
||||
* mixed-protocol security policy, which is why the old file://-only
|
||||
* setup worked but the new HTTP setup needs this.
|
||||
*
|
||||
* Path-traversal guard: the resolved file's real path must sit inside
|
||||
* one of the configured roots; the request 404s otherwise.
|
||||
*/
|
||||
class AudioController extends Controller
|
||||
{
|
||||
public function stream(Request $request): Response
|
||||
{
|
||||
$b64 = (string) $request->query('p', '');
|
||||
if ($b64 === '') abort(400, 'missing p');
|
||||
|
||||
// URL-safe base64; pad back to a multiple of 4 for strict decoding.
|
||||
$padded = $b64 . str_repeat('=', (4 - strlen($b64) % 4) % 4);
|
||||
$path = base64_decode(strtr($padded, '-_', '+/'), true);
|
||||
if ($path === false || $path === '') abort(400, 'bad encoding');
|
||||
|
||||
$real = realpath($path);
|
||||
if ($real === false || !is_file($real)) abort(404, 'file not found: ' . $path);
|
||||
|
||||
$allowed = false;
|
||||
foreach ((array) config('rig.music_dirs') as $root) {
|
||||
$rootReal = realpath($root);
|
||||
if ($rootReal && str_starts_with($real . '/', rtrim($rootReal, '/') . '/')) {
|
||||
$allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$allowed) abort(403, 'outside allowed roots');
|
||||
|
||||
$ext = strtolower(pathinfo($real, PATHINFO_EXTENSION));
|
||||
$mime = match ($ext) {
|
||||
'm4a', 'aac' => 'audio/mp4',
|
||||
'mp3' => 'audio/mpeg',
|
||||
'opus', 'ogg'=> 'audio/ogg',
|
||||
'flac' => 'audio/flac',
|
||||
'webm' => 'audio/webm',
|
||||
'wav' => 'audio/wav',
|
||||
default => 'application/octet-stream',
|
||||
};
|
||||
|
||||
// BinaryFileResponse handles Range requests automatically, which the
|
||||
// HTML5 <audio> element issues when seeking.
|
||||
$resp = new BinaryFileResponse($real, 200, [
|
||||
'Content-Type' => $mime,
|
||||
'Accept-Ranges' => 'bytes',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
return $resp;
|
||||
}
|
||||
|
||||
/** Encode an absolute path into the URL-safe base64 used by the {@see stream} route. */
|
||||
public static function urlFor(string $absolutePath): string
|
||||
{
|
||||
return '/track?p=' . rtrim(strtr(base64_encode($absolutePath), '+/', '-_'), '=');
|
||||
}
|
||||
}
|
||||
8
webapp/app/Http/Controllers/Controller.php
Normal file
8
webapp/app/Http/Controllers/Controller.php
Normal file
@@ -0,0 +1,8 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
//
|
||||
}
|
||||
78
webapp/app/Http/Controllers/DataController.php
Normal file
78
webapp/app/Http/Controllers/DataController.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Support\RigData;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
|
||||
class DataController extends Controller
|
||||
{
|
||||
/**
|
||||
* Serve the playlist as a script-tag-friendly JS global. Cached via ETag on file mtime
|
||||
* so CEF can revalidate cheaply (the playlist file changes rarely but the body is ~142 KB).
|
||||
*/
|
||||
public function playlist(Request $request, RigData $rig)
|
||||
{
|
||||
$path = $rig->playlistPath();
|
||||
$tracks = $rig->playlist();
|
||||
$mtime = is_file($path) ? filemtime($path) : 0;
|
||||
$etag = '"' . md5('playlist:' . $mtime) . '"';
|
||||
|
||||
if ($request->headers->get('If-None-Match') === $etag) {
|
||||
return response('', 304)->header('ETag', $etag);
|
||||
}
|
||||
|
||||
// Rewrite `file://` URIs into `/audio?p=...` HTTP URLs so the
|
||||
// music-daemon page (served over HTTP) can load tracks. Chromium
|
||||
// blocks HTTP-origin pages from loading file:// media.
|
||||
if (isset($tracks['tracks']) && is_array($tracks['tracks'])) {
|
||||
foreach ($tracks['tracks'] as $i => $track) {
|
||||
if (!isset($track['file'])) continue;
|
||||
if (str_starts_with($track['file'], 'file://')) {
|
||||
$absolute = rawurldecode(substr($track['file'], 7));
|
||||
$tracks['tracks'][$i]['file'] = \App\Http\Controllers\AudioController::urlFor($absolute);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$body = 'window.__PLAYLIST = ' . json_encode(
|
||||
$tracks,
|
||||
JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
|
||||
) . ';';
|
||||
|
||||
return response($body, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, must-revalidate',
|
||||
'ETag' => $etag,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve bridges/cover.jpg. `no-store` so CEF never holds a stale image
|
||||
* even if a caller forgets the ?v=<hash> cache-bust. 404 returns a 1×1
|
||||
* transparent PNG so the music-box cover placeholder doesn't get a
|
||||
* broken-image icon while the bridge is starting up.
|
||||
*/
|
||||
public function coverImage(RigData $rig): SymfonyResponse
|
||||
{
|
||||
$path = $rig->coverPath();
|
||||
if (is_file($path) && is_readable($path)) {
|
||||
return response()->file($path, [
|
||||
'Content-Type' => 'image/jpeg',
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
|
||||
// 1x1 transparent PNG (67 bytes).
|
||||
$png = base64_decode(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
|
||||
);
|
||||
return response($png, 200, [
|
||||
'Content-Type' => 'image/png',
|
||||
'Cache-Control' => 'no-store, max-age=0',
|
||||
]);
|
||||
}
|
||||
}
|
||||
20
webapp/app/Http/Controllers/MusicCommandController.php
Normal file
20
webapp/app/Http/Controllers/MusicCommandController.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\ObsWsClient;
|
||||
use Illuminate\Http\Response;
|
||||
use Throwable;
|
||||
|
||||
class MusicCommandController extends Controller
|
||||
{
|
||||
public function send(string $type, ObsWsClient $ws): Response
|
||||
{
|
||||
try {
|
||||
$ws->broadcast('mpd:cmd', ['type' => $type]);
|
||||
} catch (Throwable $e) {
|
||||
return response($e->getMessage(), 502);
|
||||
}
|
||||
return response('', 204);
|
||||
}
|
||||
}
|
||||
13
webapp/app/Http/Controllers/Overlays/DesktopController.php
Normal file
13
webapp/app/Http/Controllers/Overlays/DesktopController.php
Normal file
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class DesktopController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('overlays.desktop');
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/GameController.php
Normal file
16
webapp/app/Http/Controllers/Overlays/GameController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class GameController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.game', [
|
||||
'manifest' => $rig->loading(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/GoodbyeController.php
Normal file
16
webapp/app/Http/Controllers/Overlays/GoodbyeController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class GoodbyeController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.goodbye', [
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
16
webapp/app/Http/Controllers/Overlays/LandingController.php
Normal file
16
webapp/app/Http/Controllers/Overlays/LandingController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class LandingController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.landing', [
|
||||
'telemetry' => $rig->telemetry(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
18
webapp/app/Http/Controllers/Overlays/LoadingController.php
Normal file
18
webapp/app/Http/Controllers/Overlays/LoadingController.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class LoadingController extends Controller
|
||||
{
|
||||
public function show(RigData $rig)
|
||||
{
|
||||
return view('overlays.loading', [
|
||||
'manifest' => $rig->loading(),
|
||||
'telemetry' => $rig->telemetry(),
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
33
webapp/app/Http/Controllers/Overlays/MusicBoxController.php
Normal file
33
webapp/app/Http/Controllers/Overlays/MusicBoxController.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Support\RigData;
|
||||
|
||||
class MusicBoxController extends Controller
|
||||
{
|
||||
public function box(RigData $rig)
|
||||
{
|
||||
return view('overlays.music.box', [
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function widget()
|
||||
{
|
||||
return view('overlays.music.widget');
|
||||
}
|
||||
|
||||
public function cover()
|
||||
{
|
||||
return view('overlays.music.cover');
|
||||
}
|
||||
|
||||
public function nc(RigData $rig)
|
||||
{
|
||||
return view('overlays.music.nc', [
|
||||
'trackCount' => $rig->playlistCount(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Overlays;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
|
||||
class MusicDaemonController extends Controller
|
||||
{
|
||||
public function show()
|
||||
{
|
||||
return view('overlays.music.daemon');
|
||||
}
|
||||
}
|
||||
14
webapp/app/Providers/RigDataServiceProvider.php
Normal file
14
webapp/app/Providers/RigDataServiceProvider.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\RigData;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class RigDataServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(RigData::class);
|
||||
}
|
||||
}
|
||||
186
webapp/app/Services/HardwareSnapshot.php
Normal file
186
webapp/app/Services/HardwareSnapshot.php
Normal file
@@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Captures the static device + OBS config snapshot shown on the landing
|
||||
* overlay's telemetry rotator. Direct port of scripts/telemetry.sh.
|
||||
*/
|
||||
class HardwareSnapshot
|
||||
{
|
||||
public function collect(): array
|
||||
{
|
||||
return [
|
||||
'collectedAt' => gmdate('Y-m-d\TH:i:s\Z'),
|
||||
'rig' => $this->rigName(),
|
||||
'cpu' => $this->cpu(),
|
||||
'mem' => $this->mem(),
|
||||
'host' => ['kernel' => $this->kernel()],
|
||||
'gpu' => $this->gpu(),
|
||||
'obs' => $this->obs(),
|
||||
];
|
||||
}
|
||||
|
||||
private function rigName(): string
|
||||
{
|
||||
$name = (string) (config('rig.rig_name') ?: gethostname());
|
||||
return ucfirst(strtolower($name));
|
||||
}
|
||||
|
||||
private function cpu(): array
|
||||
{
|
||||
$model = null;
|
||||
if (is_readable('/proc/cpuinfo')) {
|
||||
foreach (file('/proc/cpuinfo') as $line) {
|
||||
if (preg_match('/^model name\s*:\s*(.+)$/', $line, $m)) {
|
||||
$model = trim($m[1]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$threads = (int) (shell_exec('nproc 2>/dev/null') ?: 0);
|
||||
return ['model' => $model, 'threads' => $threads ?: null];
|
||||
}
|
||||
|
||||
private function mem(): array
|
||||
{
|
||||
$kb = 0;
|
||||
if (is_readable('/proc/meminfo')) {
|
||||
foreach (file('/proc/meminfo') as $line) {
|
||||
if (preg_match('/^MemTotal:\s+(\d+)\s*kB/', $line, $m)) {
|
||||
$kb = (int) $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$gb = $kb > 0 ? round($kb / 1024 / 1024, 1) : null;
|
||||
return ['totalGB' => $gb];
|
||||
}
|
||||
|
||||
private function kernel(): ?string
|
||||
{
|
||||
$k = trim((string) shell_exec('uname -r 2>/dev/null'));
|
||||
return $k === '' ? null : $k;
|
||||
}
|
||||
|
||||
private function gpu(): array
|
||||
{
|
||||
$name = null;
|
||||
$vram = null;
|
||||
if (trim((string) shell_exec('command -v nvidia-smi 2>/dev/null')) !== '') {
|
||||
$row = trim((string) shell_exec(
|
||||
'nvidia-smi --query-gpu=name,memory.total --format=csv,noheader,nounits 2>/dev/null | head -n1'
|
||||
));
|
||||
if ($row !== '') {
|
||||
[$n, $v] = array_pad(array_map('trim', explode(',', $row, 2)), 2, null);
|
||||
$name = $n !== '' ? $n : null;
|
||||
$vram = is_numeric($v) ? (int) $v : null;
|
||||
}
|
||||
}
|
||||
return ['name' => $name, 'vramTotalMB' => $vram];
|
||||
}
|
||||
|
||||
private function obs(): ?array
|
||||
{
|
||||
$obsDir = rtrim((string) config('rig.obs_studio_dir'), '/');
|
||||
$userIni = $obsDir . '/user.ini';
|
||||
if (!is_file($userIni)) return null;
|
||||
$user = $this->parseIni($userIni);
|
||||
|
||||
$profile = $user['Basic']['Profile'] ?? 'Untitled';
|
||||
$renderer = $user['Video']['Renderer'] ?? null;
|
||||
|
||||
$profileDir = "{$obsDir}/basic/profiles/{$profile}";
|
||||
$basicIni = "{$profileDir}/basic.ini";
|
||||
if (!is_file($basicIni)) return null;
|
||||
$b = $this->parseIni($basicIni);
|
||||
|
||||
$mode = $b['Output']['Mode'] ?? null;
|
||||
$baseW = isset($b['Video']['BaseCX']) ? (int) $b['Video']['BaseCX'] : null;
|
||||
$baseH = isset($b['Video']['BaseCY']) ? (int) $b['Video']['BaseCY'] : null;
|
||||
$outW = isset($b['Video']['OutputCX']) ? (int) $b['Video']['OutputCX'] : null;
|
||||
$outH = isset($b['Video']['OutputCY']) ? (int) $b['Video']['OutputCY'] : null;
|
||||
$fps = isset($b['Video']['FPSInt']) ? (int) $b['Video']['FPSInt'] : null;
|
||||
|
||||
$encRaw = '';
|
||||
$bitrate = null;
|
||||
$rc = null;
|
||||
$keyint = null;
|
||||
$h264Profile = null;
|
||||
|
||||
if ($mode === 'Advanced') {
|
||||
$encRaw = $b['AdvOut']['Encoder'] ?? '';
|
||||
$sej = "{$profileDir}/streamEncoder.json";
|
||||
if (is_file($sej)) {
|
||||
$j = json_decode((string) file_get_contents($sej), true) ?: [];
|
||||
$rc = $j['rate_control'] ?? null;
|
||||
$bitrate = isset($j['bitrate']) ? (int) $j['bitrate'] : null;
|
||||
$keyint = isset($j['keyint_sec']) ? (int) $j['keyint_sec'] : null;
|
||||
$h264Profile = $j['profile'] ?? null;
|
||||
}
|
||||
} else {
|
||||
$encRaw = $b['SimpleOutput']['StreamEncoder'] ?? '';
|
||||
$bitrate = isset($b['SimpleOutput']['VBitrate']) ? (int) $b['SimpleOutput']['VBitrate'] : null;
|
||||
}
|
||||
|
||||
return [
|
||||
'profile' => $profile,
|
||||
'renderer' => $renderer,
|
||||
'outputMode' => $mode,
|
||||
'canvas' => ['w' => $baseW, 'h' => $baseH],
|
||||
'output' => ['w' => $outW, 'h' => $outH],
|
||||
'fps' => $fps,
|
||||
'color' => [
|
||||
'format' => $b['Video']['ColorFormat'] ?? null,
|
||||
'space' => $b['Video']['ColorSpace'] ?? null,
|
||||
'range' => $b['Video']['ColorRange'] ?? null,
|
||||
],
|
||||
'stream' => [
|
||||
'encoder' => $this->prettifyEncoder($encRaw),
|
||||
'rateControl' => $rc,
|
||||
'bitrateKbps' => $bitrate,
|
||||
'keyintSec' => $keyint,
|
||||
'profile' => $h264Profile,
|
||||
],
|
||||
'audio' => [
|
||||
'sampleRateHz' => isset($b['Audio']['SampleRate']) ? (int) $b['Audio']['SampleRate'] : null,
|
||||
'channels' => $b['Audio']['ChannelSetup'] ?? null,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function prettifyEncoder(string $id): ?string
|
||||
{
|
||||
$map = [
|
||||
'obs_x264' => 'x264', 'x264' => 'x264',
|
||||
'jim_nvenc' => 'NVENC H.264', 'ffmpeg_nvenc' => 'NVENC H.264',
|
||||
'obs_nvenc_h264_tex' => 'NVENC H.264', 'obs_nvenc_h264_soft' => 'NVENC H.264',
|
||||
'obs_nvenc_hevc_tex' => 'NVENC HEVC', 'obs_nvenc_hevc_soft' => 'NVENC HEVC',
|
||||
'obs_nvenc_av1_tex' => 'NVENC AV1', 'obs_nvenc_av1_soft' => 'NVENC AV1',
|
||||
'obs_qsv11' => 'QSV',
|
||||
];
|
||||
if ($id === '') return null;
|
||||
return $map[$id] ?? $id;
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, string>> */
|
||||
private function parseIni(string $path): array
|
||||
{
|
||||
$data = [];
|
||||
$section = null;
|
||||
foreach (file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
|
||||
$trim = trim($line);
|
||||
if ($trim === '' || str_starts_with($trim, ';') || str_starts_with($trim, '#')) continue;
|
||||
if (preg_match('/^\[(.+)\]$/', $trim, $m)) {
|
||||
$section = $m[1];
|
||||
$data[$section] = $data[$section] ?? [];
|
||||
continue;
|
||||
}
|
||||
if ($section !== null && str_contains($line, '=')) {
|
||||
[$k, $v] = explode('=', $line, 2);
|
||||
$data[$section][trim($k)] = trim($v);
|
||||
}
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
158
webapp/app/Services/ObsWsClient.php
Normal file
158
webapp/app/Services/ObsWsClient.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?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 30–80 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));
|
||||
}
|
||||
}
|
||||
92
webapp/app/Services/TwitchHelix.php
Normal file
92
webapp/app/Services/TwitchHelix.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Thin Twitch Helix client. Replaces twitch-bot/search-game.py + set-channel.py
|
||||
* for use from `php artisan rig:loading`.
|
||||
*
|
||||
* Requires TWITCH_CLIENT_ID + TWITCH_TOKEN (user OAuth token with the
|
||||
* channel:manage:broadcast scope) and TWITCH_BROADCASTER_ID in .env.
|
||||
*/
|
||||
class TwitchHelix
|
||||
{
|
||||
private Client $http;
|
||||
private string $clientId;
|
||||
private string $token;
|
||||
private string $broadcasterId;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->clientId = (string) config('rig.twitch.client_id');
|
||||
$this->token = (string) config('rig.twitch.token');
|
||||
$this->broadcasterId = (string) config('rig.twitch.broadcaster_id');
|
||||
$this->http = new Client([
|
||||
'base_uri' => 'https://api.twitch.tv/helix/',
|
||||
'timeout' => 8.0,
|
||||
]);
|
||||
}
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return $this->clientId !== '' && $this->token !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns [['id' => '12345', 'name' => 'Half-Life 2'], …] for an exact-name match,
|
||||
* or null if Twitch returned no matches.
|
||||
*/
|
||||
public function searchCategory(string $query): ?array
|
||||
{
|
||||
try {
|
||||
$res = $this->http->get('search/categories', [
|
||||
'headers' => $this->headers(),
|
||||
'query' => ['query' => $query, 'first' => 20],
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new RuntimeException('Twitch search failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
$body = json_decode((string) $res->getBody(), true);
|
||||
$hits = $body['data'] ?? [];
|
||||
if (empty($hits)) return null;
|
||||
|
||||
// Prefer exact (case-insensitive) match; otherwise the first hit Twitch returned.
|
||||
foreach ($hits as $row) {
|
||||
if (strcasecmp((string) ($row['name'] ?? ''), $query) === 0) {
|
||||
return ['id' => (string) $row['id'], 'name' => (string) $row['name']];
|
||||
}
|
||||
}
|
||||
return ['id' => (string) $hits[0]['id'], 'name' => (string) $hits[0]['name']];
|
||||
}
|
||||
|
||||
public function setChannel(string $title, ?string $gameId = null): void
|
||||
{
|
||||
if ($this->broadcasterId === '') {
|
||||
throw new RuntimeException('TWITCH_BROADCASTER_ID not set in .env');
|
||||
}
|
||||
$body = ['title' => $title];
|
||||
if ($gameId !== null && $gameId !== '') $body['game_id'] = $gameId;
|
||||
|
||||
try {
|
||||
$this->http->patch('channels', [
|
||||
'headers' => $this->headers() + ['Content-Type' => 'application/json'],
|
||||
'query' => ['broadcaster_id' => $this->broadcasterId],
|
||||
'json' => $body,
|
||||
]);
|
||||
} catch (GuzzleException $e) {
|
||||
throw new RuntimeException('Twitch PATCH /helix/channels failed: ' . $e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
private function headers(): array
|
||||
{
|
||||
return [
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
'Client-Id' => $this->clientId,
|
||||
];
|
||||
}
|
||||
}
|
||||
87
webapp/app/Support/RigData.php
Normal file
87
webapp/app/Support/RigData.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
class RigData
|
||||
{
|
||||
private array $cache = [];
|
||||
|
||||
public function loading(): array
|
||||
{
|
||||
return $this->read('loading.json', [
|
||||
'game' => null, 'gameId' => null, 'subtitle' => null,
|
||||
'countdownMin' => 5, 'camera' => true, 'microphone' => true,
|
||||
'compiledAt' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function telemetry(): array
|
||||
{
|
||||
return $this->read('telemetry.json', [
|
||||
'rig' => config('rig.rig_name') ?? 'unknown',
|
||||
'host' => [], 'cpu' => [], 'gpu' => [], 'mem' => [], 'obs' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function playlist(): array
|
||||
{
|
||||
return $this->read('playlist.json', [
|
||||
'syncedAt' => null, 'sourceDirs' => [], 'trackCount' => 0, 'tracks' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
public function playlistCount(): int
|
||||
{
|
||||
$p = $this->playlist();
|
||||
return (int) ($p['trackCount'] ?? count($p['tracks'] ?? []));
|
||||
}
|
||||
|
||||
public function cmd(): array
|
||||
{
|
||||
return $this->read('cmd.json', ['id' => 0, 'type' => 'none', 'ts' => 0]);
|
||||
}
|
||||
|
||||
public function writeCmd(string $type): array
|
||||
{
|
||||
$cmd = ['id' => (int) (microtime(true) * 1000), 'type' => $type, 'ts' => time()];
|
||||
$this->write('cmd.json', $cmd);
|
||||
$this->cache['cmd.json'] = $cmd;
|
||||
return $cmd;
|
||||
}
|
||||
|
||||
public function playlistPath(): string
|
||||
{
|
||||
return rtrim(config('rig.storage_data'), '/') . '/playlist.json';
|
||||
}
|
||||
|
||||
public function coverPath(): string
|
||||
{
|
||||
return (string) config('rig.cover_jpg');
|
||||
}
|
||||
|
||||
private function read(string $file, array $fallback): array
|
||||
{
|
||||
if (isset($this->cache[$file])) return $this->cache[$file];
|
||||
|
||||
$path = rtrim(config('rig.storage_data'), '/') . '/' . $file;
|
||||
if (!is_file($path)) return $this->cache[$file] = $fallback;
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
if ($raw === false) return $this->cache[$file] = $fallback;
|
||||
|
||||
$parsed = json_decode($raw, true);
|
||||
if (!is_array($parsed)) return $this->cache[$file] = $fallback;
|
||||
|
||||
return $this->cache[$file] = $parsed + $fallback;
|
||||
}
|
||||
|
||||
private function write(string $file, array $data): void
|
||||
{
|
||||
$dir = rtrim(config('rig.storage_data'), '/');
|
||||
if (!is_dir($dir)) @mkdir($dir, 0775, true);
|
||||
file_put_contents(
|
||||
$dir . '/' . $file,
|
||||
json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user