Files
obs-config/webapp/app/Http/Controllers/AudioController.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

72 lines
2.6 KiB
PHP

<?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), '+/', '-_'), '=');
}
}