Files
obs-config/bridges/mpd-state.py
2026-04-27 21:10:13 +02:00

540 lines
22 KiB
Python
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 python3
"""
MPD → OBS WebSocket bridge.
Re-broadcasts MPD playback state as `mpd:state` CustomEvents on the OBS
WebSocket bus. The loading overlay (loading/index.html) already listens for
those events to drive its now-playing terminal line — this script replaces
the custom music/index.html browser daemon as the source of truth.
Payload shape matches the original daemon's broadcastState() so the overlay
needs zero changes:
{ _type, index, total, title, artist, trackTitle, album, year, genre,
file, currentTime, duration, paused,
nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles,
coverPath, coverHash }
Next-track fields are populated when MPD reports `nextsong` in its status.
`nextTitles` is up to 3 display strings peeked from the queue starting at
the current song's successor — used by the Music Box overlay to show
"coming up" lines. The single `nextTitle`/`nextArtist`/etc. fields mirror
nextTitles[0] for overlays that only need one.
Library stats (artists/albums/songs/db_playtime) come from MPD `stats`.
That command is cheap but not free, and the results barely change between
ticks (only when files are added/removed) — so we cache and refresh every
STATS_REFRESH_SEC. Exposed under the `stats` key:
stats: { artists, albums, songs, dbPlaytime } # dbPlaytime is human-formatted
Cover art resolution (run once per track change, cached):
1. Sibling file in song's directory matching folder/cover/front ×
jpg/jpeg/png/webp (case-insensitive). Covers the Kolekcja layout
where ~/HDD/Music/Kolekcja symlinks to /media/nvme/Music/Kolekcja
and each album dir ships a folder.jpg.
2. MPD `readpicture` then `albumart` commands for embedded art (yt-dlp
--embed-thumbnail tracks under the rest of ~/HDD/Music).
Bytes are written to ./cover.jpg (next to this script). Overlays load
`<img src="../bridges/cover.jpg?v=<coverHash>">` so a hash bump cache-busts
cleanly. coverHash null = no art available; overlays should show a
placeholder. Reads MPD library root from MPD_MUSIC_DIR env var, default
~/HDD/Music (matches mpd.conf).
Reads OBS WS credentials from ../vendor/obs-config.js (auto-generated by
scripts/setup.sh from plugin_config/obs-websocket/config.json — single source of truth).
Run via systemd: systemctl --user enable --now obs-mpd-bridge.service
Tail logs with: journalctl --user -u obs-mpd-bridge -f
"""
import asyncio
import base64
import hashlib
import json
import os
import re
import sys
import time
from pathlib import Path
import websockets
from mpd.asyncio import MPDClient
HERE = Path(__file__).resolve().parent
OBS_CONFIG_JS = HERE.parent / "vendor" / "obs-config.js"
MPD_HOST = os.environ.get("MPD_HOST", "localhost")
MPD_PORT = int(os.environ.get("MPD_PORT", "6600"))
MPD_MUSIC_DIR = Path(os.environ.get("MPD_MUSIC_DIR", "~/HDD/Music")).expanduser()
TICK_SECONDS = 1.0 # broadcast cadence; matches the old daemon's setInterval(broadcastState, 1000)
STATS_REFRESH_SEC = 30.0 # how often to re-poll MPD `stats` for library counts
COVER_OUT = HERE / "cover.jpg" # canonical cover bytes; overlays cache-bust via ?v=<coverHash>
COVER_STEMS = ("folder", "cover", "front")
COVER_EXTS = (".jpg", ".jpeg", ".png", ".webp")
# ─── config + auth ──────────────────────────────────────────────────────────
def load_obs_config(path: Path):
"""Extract `url` and `password` from the JS config file by regex —
avoids requiring a JS parser for two trivial string assignments."""
text = path.read_text()
url_m = re.search(r"url:\s*'([^']+)'", text)
pw_m = re.search(r"password:\s*'([^']*)'", text)
if not url_m:
raise RuntimeError(f"no `url` field in {path}")
return url_m.group(1), (pw_m.group(1) if pw_m else "")
def obs_auth_response(password: str, salt: str, challenge: str) -> str:
"""OBS WebSocket v5 auth: base64(sha256(b64(sha256(pw+salt)) + challenge))."""
secret_b64 = base64.b64encode(
hashlib.sha256((password + salt).encode()).digest()
).decode()
return base64.b64encode(
hashlib.sha256((secret_b64 + challenge).encode()).digest()
).decode()
# ─── state translation ──────────────────────────────────────────────────────
def _flatten_tag(v):
"""MPD returns multi-value tags as lists (e.g. two ARTIST lines for a
feature collab). Join with `, ` and trim; treat empty as None."""
if v is None:
return None
if isinstance(v, list):
v = ", ".join(x for x in v if x)
v = v.strip()
return v or None
def _song_display(song: dict) -> tuple:
"""Extract (display, artist, track_title) from an MPD song dict.
Display string is built `Artist - Title` from ID3/Vorbis tags when both
are present, then degrades through tag-only → filename stem (already
"Artist - Title" for the NCS Directory naming convention) → None."""
artist = _flatten_tag(song.get("artist") or song.get("albumartist"))
track_name = _flatten_tag(song.get("title"))
if artist and track_name:
display = f"{artist} - {track_name}"
elif track_name:
display = track_name
elif song.get("file"):
display = Path(song["file"]).stem.strip() or None
else:
display = None
return display, artist, track_name
def _format_dbplaytime(seconds: float) -> str:
"""MPD's db_playtime is the total runtime of every file in the library
in seconds. Render the most-significant two units (47d 12h, 5h 22m, …)
so the stats line stays compact even at multi-day libraries."""
s = int(seconds or 0)
days, s = divmod(s, 86400)
hours, s = divmod(s, 3600)
mins, _ = divmod(s, 60)
if days: return f"{days}d {hours}h"
if hours: return f"{hours}h {mins}m"
if mins: return f"{mins}m"
return "0m"
def _status_bool(status: dict, key: str) -> bool:
"""MPD status flags arrive as "0"/"1" strings; normalize to bool."""
return str(status.get(key, "0")).strip() == "1"
def _status_volume(status: dict) -> int | None:
"""Volume is usually 0..100, but MPD can report -1 when unavailable."""
raw = status.get("volume")
if raw is None:
return None
try:
volume = int(raw)
except (TypeError, ValueError):
return None
return volume if volume >= 0 else None
# Module-level cache: MPD stats barely move between ticks (only on file
# add/remove), so we refresh every STATS_REFRESH_SEC and reuse otherwise.
_stats_cache = {"data": None, "next_refresh": 0.0}
async def refresh_stats(client) -> dict | None:
now = time.monotonic()
if _stats_cache["data"] and now < _stats_cache["next_refresh"]:
return _stats_cache["data"]
try:
s = await client.stats()
_stats_cache["data"] = {
"artists": int(s.get("artists") or 0),
"albums": int(s.get("albums") or 0),
"songs": int(s.get("songs") or 0),
"dbPlaytime": _format_dbplaytime(float(s.get("db_playtime") or 0)),
}
_stats_cache["next_refresh"] = now + STATS_REFRESH_SEC
except Exception as e:
# Keep last-known on transient failure — overlays prefer stale to
# blank for slow-moving counters like this.
print(f"[stats] refresh failed: {e}", flush=True)
return _stats_cache["data"]
def mpd_to_event(status: dict, song: dict, next_songs: list | None = None,
cover_path: str | None = None,
cover_hash: str | None = None,
stats: dict | None = None) -> dict:
"""Translate `status` + `currentsong` (+ optional `next_songs`, the next
up to 3 queued tracks) into the overlay payload. The single `nextTitle`
family mirrors `nextTitles[0]` for overlays that only need one."""
paused = status.get("state", "stop") != "play"
elapsed = float(status.get("elapsed") or 0)
duration = float(status.get("duration") or song.get("duration") or 0)
display, artist, track_name = _song_display(song)
album = _flatten_tag(song.get("album"))
# MPD's date tag is whatever the file carries — ID3v2 TYER/TDRC, Vorbis
# DATE, etc. Often a 4-digit year, sometimes a full ISO date. Take the
# leading 4 digits when present so consumers always get a clean year.
raw_date = _flatten_tag(song.get("date") or song.get("originaldate"))
year = None
if raw_date:
m = re.match(r"\s*(\d{4})", raw_date)
year = m.group(1) if m else raw_date
genre = _flatten_tag(song.get("genre"))
playback = {
"volume": _status_volume(status),
"repeat": _status_bool(status, "repeat"),
"random": _status_bool(status, "random"),
"single": _status_bool(status, "single"),
"consume": _status_bool(status, "consume"),
}
next_titles = []
next_display, next_artist, next_track, next_file = None, None, None, None
if next_songs:
for ns in next_songs:
d, a, t = _song_display(ns)
if d is not None:
next_titles.append(d)
if next_songs[0]:
next_display, next_artist, next_track = _song_display(next_songs[0])
next_file = next_songs[0].get("file")
return {
"index": int(status["song"]) if "song" in status else -1,
"total": int(status.get("playlistlength") or 0),
"title": display,
"artist": artist,
"trackTitle": track_name,
"album": album,
"year": year,
"genre": genre,
"file": song.get("file"),
"currentTime": elapsed,
"duration": duration,
"paused": paused,
"nextTitle": next_display,
"nextArtist": next_artist,
"nextTrackTitle": next_track,
"nextFile": next_file,
"nextTitles": next_titles,
"coverPath": cover_path,
"coverHash": cover_hash,
"playback": playback,
"stats": stats,
}
class SharedState:
def __init__(self):
self.data = {
"index": -1, "total": 0,
"title": None, "artist": None, "trackTitle": None,
"album": None, "year": None, "genre": None, "file": None,
"currentTime": 0, "duration": 0, "paused": True,
"nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None,
"nextTitles": [],
"coverPath": None, "coverHash": None,
"playback": {"volume": None, "repeat": False, "random": False, "single": False, "consume": False},
"stats": None,
}
self.changed = asyncio.Event()
# ─── cover art resolution ───────────────────────────────────────────────────
class CoverState:
"""Caches the last resolved (uri → hash) so we only re-run cover lookup
on actual track change, not every status tick. The hash also lets the
overlay cache-bust without us re-reading the file each second."""
def __init__(self):
self.last_uri: str | None = None
self.last_hash: str | None = None
_cover_state = CoverState()
def _find_folder_cover(song_uri: str) -> Path | None:
"""Look in the song's own directory for folder/cover/front.{jpg,jpeg,
png,webp}. Case-insensitive. song_uri is MPD-relative (e.g.
"Kolekcja/VBS - Nikoś/01 - Track.mp3"); resolve() follows the symlink
so /media/nvme/Music/Kolekcja files are found.
Lookup is prioritized: stems in COVER_STEMS order × exts in COVER_EXTS
order. So when an album ships both `folder.jpg` and `folder.webp` (the
Kolekcja convention), the jpg wins — keeps the on-disk cache file's
extension aligned with its bytes most of the time."""
if not song_uri:
return None
try:
full = (MPD_MUSIC_DIR / song_uri).resolve()
except (OSError, RuntimeError):
return None
parent = full.parent
if not parent.is_dir():
return None
# Snapshot dir as lower-name → Path so prioritized lookup is O(1) and
# case-insensitive without re-scanning per candidate.
try:
by_lower = {p.name.lower(): p for p in parent.iterdir() if p.is_file()}
except OSError:
return None
for stem in COVER_STEMS:
for ext in COVER_EXTS:
hit = by_lower.get(f"{stem}{ext}")
if hit:
return hit
return None
async def _fetch_embedded_art(client, uri: str) -> bytes | None:
"""Try MPD `readpicture` first (preferred — works for any tagged file
with embedded art) then fall back to `albumart` (older, MPD-specific
sidecar lookup). Both stream chunks; we loop with offset until size
is reached."""
for cmd_name in ("readpicture", "albumart"):
cmd = getattr(client, cmd_name, None)
if cmd is None:
continue
try:
buf = bytearray()
offset = 0
while True:
resp = await cmd(uri, offset)
if not isinstance(resp, dict):
break
chunk = resp.get("binary")
if not chunk:
break
buf.extend(chunk)
size = int(resp.get("size") or 0)
offset = len(buf)
if size and offset >= size:
break
if buf:
return bytes(buf)
except Exception:
# MPD raises when the file lacks embedded art for the given
# command — that's the signal to try the next one.
continue
return None
async def resolve_cover(client, song_uri: str | None) -> tuple[str | None, str | None]:
"""Resolve cover art for the current song; return (coverPath, coverHash).
Writes bytes to COVER_OUT only when the hash changes. Returns (None, None)
when no art is available — overlays should render a placeholder.
Caches by `song_uri`: re-running disk reads + MPD readpicture each tick
would be wasteful. State is reset on track change (or song_uri=None).
"""
if not song_uri:
_cover_state.last_uri = None
_cover_state.last_hash = None
return None, None
if song_uri == _cover_state.last_uri:
if _cover_state.last_hash:
return f"../bridges/{COVER_OUT.name}", _cover_state.last_hash
return None, None
_cover_state.last_uri = song_uri
data: bytes | None = None
folder = _find_folder_cover(song_uri)
if folder:
try:
data = folder.read_bytes()
except OSError:
data = None
if not data:
try:
data = await _fetch_embedded_art(client, song_uri)
except Exception:
data = None
if not data:
_cover_state.last_hash = None
return None, None
digest = hashlib.sha1(data).hexdigest()[:12]
if digest != _cover_state.last_hash:
try:
COVER_OUT.write_bytes(data)
except OSError as e:
print(f"[cover] write failed: {e}", flush=True)
_cover_state.last_hash = None
return None, None
_cover_state.last_hash = digest
return f"../bridges/{COVER_OUT.name}", digest
# ─── MPD side ───────────────────────────────────────────────────────────────
async def mpd_loop(state: SharedState):
"""Poll MPD status every TICK_SECONDS, push into shared state."""
while True:
client = MPDClient()
try:
await client.connect(MPD_HOST, MPD_PORT)
print(f"[mpd] connected → {MPD_HOST}:{MPD_PORT} "
f"(proto {client.mpd_version})", flush=True)
while True:
status = await client.status()
song = await client.currentsong()
# Peek the next up-to-3 tracks. MPD's `nextsong` is the index
# of the immediate successor; we walk forward from there until
# we hit the queue end or 3 entries, whichever comes first.
next_songs = []
if "nextsong" in status:
start = int(status["nextsong"])
total = int(status.get("playlistlength") or 0)
for i in range(start, min(start + 3, total)):
try:
rows = await client.playlistinfo(i)
if rows:
next_songs.append(rows[0])
except Exception:
break # transient — let it retry next tick
cover_path, cover_hash = await resolve_cover(client, song.get("file"))
stats = await refresh_stats(client)
state.data = mpd_to_event(status, song, next_songs,
cover_path, cover_hash, stats)
state.changed.set()
await asyncio.sleep(TICK_SECONDS)
except Exception as e:
print(f"[mpd] {type(e).__name__}: {e} — reconnect in 2s", flush=True)
try: client.disconnect()
except Exception: pass
await asyncio.sleep(2)
# ─── OBS WS side ────────────────────────────────────────────────────────────
async def obs_loop(state: SharedState, url: str, password: str):
"""Connect to OBS WS, identify, broadcast state changes as mpd:state.
Quiet when OBS isn't running: logs only state transitions
(down→up, up→down) and uses exponential backoff so the journal doesn't
fill with reconnect spam during long OBS-off windows.
"""
backoff = 2 # current retry delay in seconds
BACKOFF_MAX = 30 # cap; OBS doesn't take long to wake up once started
prev_status = None # None | "up" | "down" — only log on transitions
while True:
try:
async with websockets.connect(url, ping_interval=20) as ws:
# ── Hello (op 0) → Identify (op 1) → Identified (op 2) ──
hello = json.loads(await ws.recv())
if hello.get("op") != 0:
raise RuntimeError(f"expected Hello, got op={hello.get('op')}")
identify = {"rpcVersion": 1, "eventSubscriptions": 0}
if hello["d"].get("authentication"):
a = hello["d"]["authentication"]
identify["authentication"] = obs_auth_response(
password, a["salt"], a["challenge"],
)
await ws.send(json.dumps({"op": 1, "d": identify}))
ident = json.loads(await ws.recv())
if ident.get("op") != 2:
raise RuntimeError(f"expected Identified, got op={ident.get('op')}")
if prev_status != "up":
print(f"[obs] connected → {url} (identified ✓)", flush=True)
prev_status = "up"
backoff = 2 # reset for next disconnect
# ── Broadcast loop + recv drain ──
# We send ~1 BroadcastCustomEvent per second; OBS replies to
# each with an op=7 RequestResponse we don't care about. If we
# never recv, the socket's read buffer fills and eventually
# TCP backpressure stalls our sends — so a parallel task
# consumes and discards everything inbound.
async def drain():
async for _ in ws:
pass
async def broadcast():
# Wake on every state change. A safety timeout slightly
# larger than TICK_SECONDS guards against a stalled MPD
# loop — we still emit a heartbeat so the overlay knows
# we're up.
while True:
try:
await asyncio.wait_for(
state.changed.wait(), timeout=TICK_SECONDS + 1.0,
)
except asyncio.TimeoutError:
pass
state.changed.clear()
payload = dict(state.data)
payload["_type"] = "mpd:state"
request = {
"op": 6,
"d": {
"requestType": "BroadcastCustomEvent",
"requestId": f"r_{time.monotonic_ns()}",
"requestData": {"eventData": payload},
},
}
await ws.send(json.dumps(request))
await asyncio.gather(drain(), broadcast())
except Exception as e:
# Only log the first time OBS becomes unreachable, or when an
# established connection drops. Routine "still down" retries
# are silent.
if prev_status == "up":
print(f"[obs] disconnected ({type(e).__name__}: {e}) — "
f"retrying with backoff", flush=True)
elif prev_status is None:
print(f"[obs] unreachable ({type(e).__name__}) — "
f"will keep retrying silently", flush=True)
prev_status = "down"
await asyncio.sleep(backoff)
backoff = min(backoff * 2, BACKOFF_MAX)
# ─── entrypoint ─────────────────────────────────────────────────────────────
async def main():
if not OBS_CONFIG_JS.exists():
print(f"[err] missing {OBS_CONFIG_JS} — run scripts/setup.sh first",
file=sys.stderr, flush=True)
sys.exit(1)
url, password = load_obs_config(OBS_CONFIG_JS)
state = SharedState()
await asyncio.gather(mpd_loop(state), obs_loop(state, url, password))
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
sys.exit(0)