613 lines
25 KiB
Python
Executable File
613 lines
25 KiB
Python
Executable File
#!/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,
|
||
audio: { format, samplerate, bits, channels, bitrate } }
|
||
File-level audio info comes from MPD `status` (`audio` is "rate:bits:ch",
|
||
`bitrate` is kbps) and the file extension (FORMAT label — FLAC/MP3/OPUS/…).
|
||
Lossless tracks (FLAC, ALAC, WAV) report their actual bit depth; MP3/Opus
|
||
report their decode word size, which is informational only.
|
||
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
|
||
|
||
|
||
# Map file extension → display label. Anything not listed falls through to
|
||
# the uppercased extension so unusual codecs still render something readable.
|
||
_FORMAT_LABELS = {
|
||
"flac": "FLAC",
|
||
"mp3": "MP3",
|
||
"opus": "OPUS",
|
||
"ogg": "OGG",
|
||
"oga": "OGG",
|
||
"m4a": "AAC",
|
||
"mp4": "AAC",
|
||
"aac": "AAC",
|
||
"wav": "WAV",
|
||
"wv": "WAVPACK",
|
||
"ape": "APE",
|
||
"alac": "ALAC",
|
||
"webm": "WEBM",
|
||
"wma": "WMA",
|
||
}
|
||
|
||
|
||
def _detect_format(uri: str | None) -> str | None:
|
||
if not uri:
|
||
return None
|
||
ext = Path(uri).suffix.lower().lstrip(".")
|
||
if not ext:
|
||
return None
|
||
return _FORMAT_LABELS.get(ext, ext.upper())
|
||
|
||
|
||
def _parse_audio(audio_str: str | None) -> tuple[int | None, int | None, int | None]:
|
||
"""Decode MPD's `audio` status string `samplerate:bits:channels`.
|
||
`bits` may be `f` (float) or `dsd<N>` for DSD streams — return None for
|
||
anything we can't render as a plain integer; the overlay just skips it."""
|
||
if not audio_str:
|
||
return None, None, None
|
||
parts = audio_str.split(":")
|
||
if len(parts) < 3:
|
||
return None, None, None
|
||
def _maybe_int(v):
|
||
try: return int(v)
|
||
except (TypeError, ValueError): return None
|
||
return _maybe_int(parts[0]), _maybe_int(parts[1]), _maybe_int(parts[2])
|
||
|
||
|
||
def _audio_info(status: dict, song: dict) -> dict:
|
||
sr, bits, ch = _parse_audio(status.get("audio"))
|
||
bitrate = None
|
||
raw_br = status.get("bitrate")
|
||
if raw_br is not None:
|
||
try:
|
||
bitrate = int(raw_br)
|
||
except (TypeError, ValueError):
|
||
bitrate = None
|
||
# MPD reports 0 kbps for paused/stopped; surface as missing instead
|
||
# of a misleading "0 kbps".
|
||
if bitrate is not None and bitrate <= 0:
|
||
bitrate = None
|
||
return {
|
||
"format": _detect_format(song.get("file")),
|
||
"samplerate": sr,
|
||
"bits": bits,
|
||
"channels": ch,
|
||
"bitrate": bitrate,
|
||
}
|
||
|
||
|
||
# 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,
|
||
"audio": _audio_info(status, song),
|
||
}
|
||
|
||
|
||
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,
|
||
"audio": {"format": None, "samplerate": None, "bits": None, "channels": None, "bitrate": 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)
|