diff --git a/CLAUDE.md b/CLAUDE.md index 9ca7973..865a27e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,6 @@ Or via the app menu ("OBS Studio"). The native `obs` binary still exists from `p - `php artisan rig:setup` — generate `webapp/public/js/obs-config.js` + `OBS_WS_*` env from `plugin_config/obs-websocket/config.json`. - `php artisan rig:telemetry [--collect]` — refresh hardware/OBS-profile snapshot at `webapp/storage/data/telemetry.json`. - `php artisan rig:loading` — interactive manifest builder; updates Twitch channel via Helix (no Python shell-out). -- `php artisan rig:playlist` — index audio dirs with ffprobe → `webapp/storage/data/playlist.json`. - `php artisan rig:cmd ` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`). **Audio + automation surfaces:** diff --git a/README.md b/README.md index fc79206..9a2008c 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ playback from chat. through the per-app private dir. - **Laravel 13** webapp under `webapp/`, served by `php artisan serve` on `127.0.0.1:1118` and managed by **supervisord**. Routes return Blade-rendered - overlay pages and a small data API (`/data/playlist.js`, `/cover.jpg`, - `/track?p=…`, `POST /cmd/{skip|prev|pause|resume}`). + overlay pages and a small data API (`/cover.jpg`, `/track?p=…`, + `POST /cmd/{skip|prev|pause|resume}`). - **MPD** (system service) is the audio player. A small Python bridge (`bridges/mpd-state.py`, `obs-mpd-bridge.service`) listens to MPD and re-broadcasts state as `mpd:state` CustomEvents on the OBS WS bus. @@ -71,7 +71,7 @@ playback from chat. ├── webapp/ Laravel app — every overlay served from here │ ├── app/ │ │ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading, -│ │ │ rig:playlist, rig:cmd +│ │ │ rig:cmd │ │ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,…}, Data, │ │ │ MusicCommand, Audio │ │ ├── Services/ ObsWsClient, TwitchHelix, HardwareSnapshot @@ -107,8 +107,8 @@ sudo supervisorctl restart obs-webapp # refresh a per-session manifest before going live (game / mode / countdown) cd webapp && php artisan rig:loading -# reindex the music library (after dropping new tracks into ~/HDD/Music) -cd webapp && php artisan rig:playlist +# rescan the music library (after dropping new tracks into ~/Music/Kolekcja) +mpc update --wait # re-snapshot hardware/OBS profile telemetry (kernel/GPU/encoder changes) cd webapp && php artisan rig:telemetry --collect diff --git a/webapp/README.md b/webapp/README.md index f3cc5d1..9e8ba35 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -67,7 +67,6 @@ restart needed. | Route | Notes | |---|---| -| `GET /data/playlist.js` | Returns `window.__PLAYLIST = {...};` with ETag | | `GET /cover.jpg` | Streams `bridges/cover.jpg` (kept fresh by `mpd-state.py`) | | `GET /track?p=` | Audio stream — path-traversal-guarded | | `POST /cmd/{skip\|prev\|pause\|resume}` | Broadcasts `mpd:cmd` over OBS WebSocket | @@ -82,7 +81,6 @@ php artisan rig:setup # gen public/js/obs-config.js + OBS_WS_* in .en php artisan rig:telemetry # refresh storage/data/telemetry.json php artisan rig:telemetry --collect # re-prompt every field interactively php artisan rig:loading # interactive Project Loading manifest (CLI mirror of /onboard) -php artisan rig:playlist # reindex audio dirs → storage/data/playlist.json php artisan rig:cmd skip # CLI mirror of POST /cmd/skip ``` @@ -118,7 +116,7 @@ php artisan serve --host=127.0.0.1 --port=1118 webapp/ ├── app/ │ ├── Console/Commands/ rig:setup, rig:telemetry, rig:loading, -│ │ rig:playlist, rig:cmd +│ │ rig:cmd │ ├── Http/Controllers/ Overlays/{Landing,Loading,Game,Desktop, │ │ Goodbye,MusicBox,MusicDaemon}, Data, │ │ MusicCommand, Audio, Onboard @@ -143,7 +141,6 @@ webapp/ ├── config/rig.php storage paths, OBS WS creds, Twitch keys └── storage/data/ rig:* commands write JSON here ├── loading.json - ├── playlist.json └── telemetry.json ``` diff --git a/webapp/app/Console/Commands/RigPlaylistCommand.php b/webapp/app/Console/Commands/RigPlaylistCommand.php deleted file mode 100644 index 1add435..0000000 --- a/webapp/app/Console/Commands/RigPlaylistCommand.php +++ /dev/null @@ -1,125 +0,0 @@ -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))); - } -} diff --git a/webapp/app/Http/Controllers/DataController.php b/webapp/app/Http/Controllers/DataController.php index d2c750e..df04809 100644 --- a/webapp/app/Http/Controllers/DataController.php +++ b/webapp/app/Http/Controllers/DataController.php @@ -3,53 +3,10 @@ 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= cache-bust. 404 returns a 1×1 diff --git a/webapp/app/Support/RigData.php b/webapp/app/Support/RigData.php index 19a4a9b..b0bbed4 100644 --- a/webapp/app/Support/RigData.php +++ b/webapp/app/Support/RigData.php @@ -2,9 +2,13 @@ namespace App\Support; +use Symfony\Component\Process\Process; +use Throwable; + class RigData { private array $cache = []; + private ?int $mpdSongCount = null; public function loading(): array { @@ -23,17 +27,26 @@ class RigData ]); } - public function playlist(): array - { - return $this->read('playlist.json', [ - 'syncedAt' => null, 'sourceDirs' => [], 'trackCount' => 0, 'tracks' => [], - ]); - } - + /** + * Track count from MPD (the live library, queried via `mpc stats`). + * Returns 0 if mpc is missing or MPD is unreachable — callers treat + * that as "empty library" and degrade their flavor text accordingly. + */ public function playlistCount(): int { - $p = $this->playlist(); - return (int) ($p['trackCount'] ?? count($p['tracks'] ?? [])); + if ($this->mpdSongCount !== null) return $this->mpdSongCount; + + try { + $proc = new Process(['mpc', 'stats']); + $proc->setTimeout(2); + $proc->run(); + if ($proc->isSuccessful() && preg_match('/^Songs:\s*(\d+)/m', $proc->getOutput(), $m)) { + return $this->mpdSongCount = (int) $m[1]; + } + } catch (Throwable) { + // fall through + } + return $this->mpdSongCount = 0; } public function cmd(): array @@ -49,11 +62,6 @@ class RigData return $cmd; } - public function playlistPath(): string - { - return rtrim(config('rig.storage_data'), '/') . '/playlist.json'; - } - public function coverPath(): string { return (string) config('rig.cover_jpg'); diff --git a/webapp/resources/views/overlays/goodbye.blade.php b/webapp/resources/views/overlays/goodbye.blade.php index 9152c21..5562c43 100644 --- a/webapp/resources/views/overlays/goodbye.blade.php +++ b/webapp/resources/views/overlays/goodbye.blade.php @@ -26,7 +26,6 @@ @push('data-scripts') - @endpush @section('content') @@ -140,8 +139,7 @@ return parts.join(' '); } - const playlistData = window.__PLAYLIST || { tracks: [] }; - const allTracks = playlistData.tracks || []; + const trackCount = window.__TRACK_COUNT || 0; let bootStarted = false; let bootFinished = false; let lastPlaybackModes = null; @@ -206,12 +204,12 @@ bootStarted = true; lastPlaybackModes = normalizePlayback(s.playback); await typeCommand(formatMusicCommand(s.playback)); - if (allTracks.length === 0) { + if (trackCount === 0) { append('> ', '[audio] no tracks queued', 'term-dim'); bootFinished = true; return; } - append('> ', `[audio] queue retained: ${allTracks.length} tracks`, 'term-out'); + append('> ', `[audio] queue retained: ${trackCount} tracks`, 'term-out'); await sleep(180); append('> ', '[audio] subscribing to mpd:state events', 'term-out'); await sleep(180); diff --git a/webapp/resources/views/overlays/loading.blade.php b/webapp/resources/views/overlays/loading.blade.php index 419b4a8..a62615b 100644 --- a/webapp/resources/views/overlays/loading.blade.php +++ b/webapp/resources/views/overlays/loading.blade.php @@ -30,7 +30,6 @@ window.__TEL = @json($telemetry); window.__TRACK_COUNT = @json($trackCount); - @endpush @section('content') @@ -142,8 +141,7 @@ return parts.join(' '); } - const playlistData = window.__PLAYLIST || { tracks: [] }; - const allTracks = playlistData.tracks || []; + const trackCount = window.__TRACK_COUNT || 0; let bootStarted = false; let bootFinished = false; let lastPlaybackModes = null; @@ -214,13 +212,13 @@ lastPlaybackModes = normalizePlayback(s.playback); await typeCommand(formatMusicCommand(s.playback)); - if (allTracks.length === 0) { - append('> ', '[audio] no tracks queued — run `php artisan rig:playlist`', 'term-dim'); + if (trackCount === 0) { + append('> ', '[audio] mpd library empty — check music_directory + run `mpc update`', 'term-dim'); bootFinished = true; return; } - append('> ', `[audio] indexed ${allTracks.length} tracks`, 'term-out'); + append('> ', `[audio] indexed ${trackCount} tracks`, 'term-out'); await sleep(180); append('> ', `[audio] uplink to daemon @ ophi-118://${rigName.toLowerCase()}.audio.bus`, 'term-out'); await sleep(180); diff --git a/webapp/resources/views/overlays/music/box.blade.php b/webapp/resources/views/overlays/music/box.blade.php index 5beba28..f16c287 100644 --- a/webapp/resources/views/overlays/music/box.blade.php +++ b/webapp/resources/views/overlays/music/box.blade.php @@ -27,7 +27,6 @@ @push('data-scripts') - @endpush @section('content') @@ -72,8 +71,7 @@ 'use strict'; const pad = HUD.pad; - const playlistData = window.__PLAYLIST || { tracks: [] }; - const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0); + const trackCount = window.__TRACK_COUNT || 0; document.getElementById('rig').textContent = trackCount > 0 ? `LIBRARY :: ${trackCount} TRACKS` : '— EMPTY LIBRARY —'; diff --git a/webapp/resources/views/overlays/music/nc.blade.php b/webapp/resources/views/overlays/music/nc.blade.php index e5944ec..e3a861c 100644 --- a/webapp/resources/views/overlays/music/nc.blade.php +++ b/webapp/resources/views/overlays/music/nc.blade.php @@ -165,7 +165,6 @@ @push('data-scripts') - @endpush @section('content') @@ -251,8 +250,7 @@ const fmtClock = s => (!isFinite(s) || s < 0) ? '--:--' : `${pad(Math.floor(s / 60))}:${pad(Math.floor(s % 60))}`; - const playlistData = window.__PLAYLIST || { tracks: [] }; - const trackCount = (playlistData.tracks || []).length || (window.__TRACK_COUNT || 0); + const trackCount = window.__TRACK_COUNT || 0; const term = document.getElementById('termOutput'); const pinnedBlock = document.getElementById('pinnedBlock'); diff --git a/webapp/resources/views/scenes.blade.php b/webapp/resources/views/scenes.blade.php index f5cb145..829344a 100644 --- a/webapp/resources/views/scenes.blade.php +++ b/webapp/resources/views/scenes.blade.php @@ -85,7 +85,6 @@
DATA ENDPOINTS
diff --git a/webapp/routes/web.php b/webapp/routes/web.php index 4cc6b5f..9935fc5 100644 --- a/webapp/routes/web.php +++ b/webapp/routes/web.php @@ -30,9 +30,8 @@ Route::prefix('music-box')->controller(MusicBoxController::class)->group(functio Route::get('nc', 'nc'); }); -Route::get('/data/playlist.js', [DataController::class, 'playlist']); -Route::get('/cover.jpg', [DataController::class, 'coverImage']); -Route::get('/track', [AudioController::class, 'stream']); +Route::get('/cover.jpg', [DataController::class, 'coverImage']); +Route::get('/track', [AudioController::class, 'stream']); Route::post('/cmd/{type}', [MusicCommandController::class, 'send']) ->whereIn('type', ['skip', 'prev', 'pause', 'resume']);