Moves the seven HTML scene dirs (landing/, loading/, game/, desktop/,
goodbye/, music-box/, music/) and the superseded bash helpers (setup.sh,
loading.sh, playlist.sh, telemetry.sh) into archived/ rather than deleting.
The Laravel webapp/ replaces all of them; archived/README.md spells out
the rollback procedure.
Also:
- rewrite the relevant sections of CLAUDE.md so it points at webapp/
blade views, the Artisan commands, and the supervisord lifecycle
(`supervisorctl restart obs-webapp` after route / controller / .env
changes; Blade view edits are still safe-while-running via OBS's
Refresh cache).
- extend scripts/deploy-rig.sh to install php + composer + supervisor,
run `composer install`, copy obs-webapp.supervisord.conf into
/etc/supervisor.d/, start the program, and call `php artisan
rig:setup` + `rig:telemetry --collect` instead of the old bash.
- .gitignore catches the generated machine-local files that came along
when the old scene dirs moved (telemetry.js, loading.json, playlist.js,
cmd.js, obs-config.js).
- daemon.blade.php is now passive — listens to mpd:state but does not
play audio or broadcast its own queue, so it stops fighting with
bridges/mpd-state.py (the post-browser-daemon-migration source of
truth for mpd:state).
- nc.blade.php overrides .terminal { overflow: hidden } from hud.css
so the `┤ TERMINAL ├` and `┤ NOW PLAYING ├` pane-tabs stick above
the pane border instead of being clipped.
186 lines
6.3 KiB
Bash
Executable File
186 lines
6.3 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# scripts/playlist.sh — playlist sync + control API for the music daemon.
|
||
#
|
||
# Usage:
|
||
# ./playlist.sh → sync (default; index audio in ../playlist/)
|
||
# ./playlist.sh sync → explicit sync
|
||
# ./playlist.sh skip → next track
|
||
# ./playlist.sh prev → previous track
|
||
# ./playlist.sh pause → pause playback
|
||
# ./playlist.sh resume → resume playback
|
||
# ./playlist.sh status → show last queued command
|
||
# ./playlist.sh help → this help
|
||
#
|
||
# Control commands (skip/prev/pause/resume) write a tiny `cmd.js` that the
|
||
# Music Daemon polls every 250ms. The daemon picks up the new command,
|
||
# acts on it, and broadcasts the resulting state via OBS WebSocket so the
|
||
# loading overlay updates live.
|
||
|
||
set -euo pipefail
|
||
|
||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
OBS_DIR="$(cd "$DIR/.." && pwd)"
|
||
JSON="$OBS_DIR/loading/playlist.json"
|
||
JS="$OBS_DIR/loading/playlist.js"
|
||
CMD_JS="$OBS_DIR/loading/cmd.js"
|
||
|
||
# Audio roots — scanned recursively, in order. Add more here as the library grows.
|
||
# Missing roots are skipped with a warning, not an error.
|
||
ROOTS=(
|
||
"$OBS_DIR/playlist"
|
||
"$HOME/HDD/Music/Electronic/NCS Directory"
|
||
)
|
||
|
||
usage() {
|
||
sed -n 's/^# \?//p' "$0" | head -20
|
||
}
|
||
|
||
# ─────────────── sync ───────────────
|
||
cmd_sync() {
|
||
if ! command -v ffprobe >/dev/null 2>&1; then
|
||
echo " ✗ ffprobe not found (install ffmpeg: sudo pacman -S ffmpeg)" >&2
|
||
exit 1
|
||
fi
|
||
|
||
echo "── Indexing audio roots ──"
|
||
for r in "${ROOTS[@]}"; do
|
||
if [[ -d "$r" ]]; then echo " + $r"; else echo " ! $r (missing — skipped)"; fi
|
||
done
|
||
|
||
local TMP="$JSON.tmp.$$"
|
||
python3 - "${ROOTS[@]}" "$TMP" <<'PY'
|
||
import json, subprocess, sys, os, datetime, re, urllib.parse
|
||
|
||
roots = sys.argv[1:-1]
|
||
out = sys.argv[-1]
|
||
exts = ('.m4a', '.mp3', '.opus', '.ogg', '.flac', '.webm', '.aac', '.wav')
|
||
|
||
def ffprobe_meta(path):
|
||
try:
|
||
r = subprocess.run(
|
||
['ffprobe', '-v', 'error',
|
||
'-show_entries', 'format=duration:format_tags=title,artist',
|
||
'-of', 'json', path],
|
||
capture_output=True, text=True, timeout=10,
|
||
)
|
||
if r.returncode != 0: return {}
|
||
fmt = (json.loads(r.stdout) or {}).get('format', {}) or {}
|
||
tags = {k.lower(): v for k, v in (fmt.get('tags') or {}).items()}
|
||
return {
|
||
'title': tags.get('title'),
|
||
'artist': tags.get('artist'),
|
||
'durationSec': float(fmt['duration']) if fmt.get('duration') else None,
|
||
}
|
||
except Exception:
|
||
return {}
|
||
|
||
def clean_title(t):
|
||
if not t: return t
|
||
# PRIMARY rule: keep only what's before the first vertical bar
|
||
# (fullwidth | U+FF5C or ASCII |). Everything after is genre/label noise.
|
||
t = re.sub(r'\s*[||].*$', '', t)
|
||
# Strip trailing YouTube-ID brackets, e.g. " [-XxZTgMWKV0]"
|
||
t = re.sub(r'\s*\[[A-Za-z0-9_-]{11}\]\s*$', '', t)
|
||
# Strip "[NCS Release]" / "(NCS10 Release)" suffix variants
|
||
t = re.sub(r'\s*[\[(](?:NCS\d*|No Copyright Sounds)(?:\s+Release)?[\])]\s*$', '', t, flags=re.I)
|
||
return t.strip()
|
||
|
||
INTERMEDIATE = re.compile(r'\.(?:f\d+|temp|part)$', re.I)
|
||
def is_audio_file(name):
|
||
base, ext = os.path.splitext(name)
|
||
if ext.lower() not in exts: return False
|
||
if INTERMEDIATE.search(base): return False
|
||
return True
|
||
|
||
# Absolute file:// URI — works across roots since the daemon HTML and the audio
|
||
# may live on different filesystems. CEF in OBS browser source loads these
|
||
# directly thanks to the Flatpak's filesystems=host permission.
|
||
def file_uri(path):
|
||
return 'file://' + urllib.parse.quote(os.path.abspath(path), safe='/')
|
||
|
||
tracks = []
|
||
for root in roots:
|
||
if not os.path.isdir(root):
|
||
print(f' ! skipping missing root: {root}', file=sys.stderr)
|
||
continue
|
||
for dirpath, _, files in os.walk(root):
|
||
for fname in sorted(files):
|
||
if not is_audio_file(fname): continue
|
||
path = os.path.join(dirpath, fname)
|
||
meta = ffprobe_meta(path)
|
||
display = clean_title(os.path.splitext(fname)[0])
|
||
tracks.append({
|
||
'title': display,
|
||
'file': file_uri(path),
|
||
'durationSec': int(meta['durationSec']) if meta.get('durationSec') else None,
|
||
})
|
||
|
||
doc = {
|
||
'syncedAt': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||
'sourceDirs': roots,
|
||
'trackCount': len(tracks),
|
||
'tracks': tracks,
|
||
}
|
||
with open(out, 'w') as f:
|
||
json.dump(doc, f, indent=2, ensure_ascii=False)
|
||
PY
|
||
|
||
if ! python3 -m json.tool "$TMP" >/dev/null 2>&1; then
|
||
echo " ✗ manifest invalid, leaving $TMP" >&2
|
||
exit 1
|
||
fi
|
||
mv -f "$TMP" "$JSON"
|
||
{ printf 'window.__PLAYLIST = '; cat "$JSON"; printf ';\n'; } > "$JS.tmp.$$"
|
||
mv -f "$JS.tmp.$$" "$JS"
|
||
|
||
local count
|
||
count=$(python3 -c "import json; print(json.load(open('$JSON'))['trackCount'])")
|
||
echo
|
||
echo " ✓ wrote $JSON ($count tracks)"
|
||
echo " ✓ wrote $JS"
|
||
echo " → refresh the Music Daemon browser source in OBS to reload the queue."
|
||
}
|
||
|
||
# ─────────────── control commands ───────────────
|
||
# Writes a unique-per-call cmd.js that the daemon polls. The id is
|
||
# nanosecond-precise so the daemon's "did this id change?" check always passes
|
||
# even on rapid-fire commands.
|
||
send_cmd() {
|
||
local type="$1"
|
||
local id ts
|
||
id=$(date +%s%N)
|
||
ts=$(date +%s)
|
||
printf 'window.__CMD = {"id":%s,"type":"%s","ts":%s};\n' "$id" "$type" "$ts" > "$CMD_JS"
|
||
echo " → ${type} (id=$id)"
|
||
}
|
||
|
||
cmd_skip() { send_cmd skip; }
|
||
cmd_prev() { send_cmd prev; }
|
||
cmd_pause() { send_cmd pause; }
|
||
cmd_resume() { send_cmd resume; }
|
||
|
||
cmd_status() {
|
||
if [[ -f "$CMD_JS" ]]; then
|
||
echo "── last queued command ──"
|
||
cat "$CMD_JS"
|
||
else
|
||
echo " (no command file yet — daemon hasn't been signalled this session)"
|
||
fi
|
||
}
|
||
|
||
# ─────────────── dispatch ───────────────
|
||
case "${1:-sync}" in
|
||
sync) cmd_sync ;;
|
||
skip|next) cmd_skip ;;
|
||
prev|back) cmd_prev ;;
|
||
pause) cmd_pause ;;
|
||
resume|play) cmd_resume ;;
|
||
status) cmd_status ;;
|
||
help|-h|--help) usage ;;
|
||
*)
|
||
echo " ✗ unknown command: $1" >&2
|
||
usage
|
||
exit 1
|
||
;;
|
||
esac
|