#!/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