webapp: drop rig:playlist pipeline, source track count from mpc stats
The four overlays that read /data/playlist.js (loading, goodbye,
music-box, music/nc) only ever consumed `tracks.length` for cosmetic
"LIBRARY :: N TRACKS" / "indexed N tracks" flavor text — all live
playback signal flows through bridges/mpd-state.py over OBS WS. The
142 KB JSON, ffprobe walk, and ETag-cached route were all producing
one integer that MPD itself already knows.
RigData::playlistCount() now shells `mpc stats` and parses "Songs: N",
returning 0 on any failure (treated by callers as empty library).
Removes RigPlaylistCommand, DataController::playlist(), the
/data/playlist.js route, the per-overlay <script src> + window.__PLAYLIST
shims, and the stale storage/data/playlist.json artifact.
AudioController still consumes config('rig.music_dirs') for the HTTP
audio stream — that's orthogonal and stays.
This commit is contained in:
@@ -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 <skip|prev|pause|resume>` — broadcast `mpd:cmd` over OBS WS (CLI mirror of `POST /cmd/{type}`).
|
||||
|
||||
**Audio + automation surfaces:**
|
||||
|
||||
10
README.md
10
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
|
||||
|
||||
@@ -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=<base64>` | 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
|
||||
```
|
||||
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<?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)));
|
||||
}
|
||||
}
|
||||
@@ -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=<hash> cache-bust. 404 returns a 1×1
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -26,7 +26,6 @@
|
||||
|
||||
@push('data-scripts')
|
||||
<script>window.__TRACK_COUNT = @json($trackCount);</script>
|
||||
<script src="{{ asset('data/playlist.js') }}"></script>
|
||||
@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);
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
window.__TEL = @json($telemetry);
|
||||
window.__TRACK_COUNT = @json($trackCount);
|
||||
</script>
|
||||
<script src="{{ asset('data/playlist.js') }}"></script>
|
||||
@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);
|
||||
|
||||
@@ -27,7 +27,6 @@
|
||||
|
||||
@push('data-scripts')
|
||||
<script>window.__TRACK_COUNT = @json($trackCount);</script>
|
||||
<script src="{{ asset('data/playlist.js') }}"></script>
|
||||
@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 —';
|
||||
|
||||
|
||||
@@ -165,7 +165,6 @@
|
||||
|
||||
@push('data-scripts')
|
||||
<script>window.__TRACK_COUNT = @json($trackCount);</script>
|
||||
<script src="{{ asset('data/playlist.js') }}"></script>
|
||||
@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');
|
||||
|
||||
@@ -85,7 +85,6 @@
|
||||
|
||||
<div class="group-title">DATA ENDPOINTS</div>
|
||||
<ul>
|
||||
<li><a class="route" href="/data/playlist.js">/data/playlist.js</a> <span class="note">— window.__PLAYLIST</span></li>
|
||||
<li><a class="route" href="/cover.jpg">/cover.jpg</a> <span class="note">— current album art</span></li>
|
||||
<li><a class="route" href="/track">/track?p=<base64></a> <span class="note">— audio stream</span></li>
|
||||
</ul>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
Reference in New Issue
Block a user