Files
obs-config/scripts/playlist.sh
2026-04-26 23:08:48 +02:00

186 lines
6.3 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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