Bridge: extend mpd:state with nextTitles[] for queue peek

Adds nextTitles (array of up to 3 display strings) plus mirrored single
nextTitle/nextArtist/nextTrackTitle/nextFile for the immediate next
track. Drives the Music Box overlays' 3-up "coming next" lines without
overlays needing to query MPD themselves.

Reads MPD status.nextsong, walks playlistinfo for the next 3 indices,
applies the existing _flatten_tag + display-string cascade. Fields are
None / [] at end of queue. Backwards-compatible: overlays that don't
read the new keys are unaffected.
This commit is contained in:
Jakub Zych
2026-04-27 03:44:54 +02:00
parent 3d23e24f1d
commit 70a5f64871

View File

@@ -9,7 +9,13 @@ the custom music/index.html browser daemon as the source of truth.
Payload shape matches the original daemon's broadcastState() so the overlay Payload shape matches the original daemon's broadcastState() so the overlay
needs zero changes: needs zero changes:
{ _type, index, total, title, file, currentTime, duration, paused } { _type, index, total, title, file, currentTime, duration, paused,
nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles }
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.
Reads OBS WS credentials from ../vendor/obs-config.js (auto-generated by 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). scripts/setup.sh from plugin_config/obs-websocket/config.json — single source of truth).
@@ -74,18 +80,12 @@ def _flatten_tag(v):
return v or None return v or None
def mpd_to_event(status: dict, song: dict) -> dict: def _song_display(song: dict) -> tuple:
"""Translate `status` + `currentsong` into the overlay's expected payload. """Extract (display, artist, track_title) from an MPD song dict.
Display string in `title` is built `Artist - Title` from ID3/Vorbis tags
when both are present, then degrades through tag-only → filename stem
(which is already "Artist - Title" for the NCS Directory naming convention)
→ None. The separate `artist` field is emitted for future overlay uses
that want to style artist + title differently."""
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 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")) artist = _flatten_tag(song.get("artist") or song.get("albumartist"))
track_name = _flatten_tag(song.get("title")) track_name = _flatten_tag(song.get("title"))
@@ -98,6 +98,30 @@ def mpd_to_event(status: dict, song: dict) -> dict:
else: else:
display = None display = None
return display, artist, track_name
def mpd_to_event(status: dict, song: dict, next_songs: list | 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)
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 { return {
"index": int(status["song"]) if "song" in status else -1, "index": int(status["song"]) if "song" in status else -1,
"total": int(status.get("playlistlength") or 0), "total": int(status.get("playlistlength") or 0),
@@ -108,6 +132,11 @@ def mpd_to_event(status: dict, song: dict) -> dict:
"currentTime": elapsed, "currentTime": elapsed,
"duration": duration, "duration": duration,
"paused": paused, "paused": paused,
"nextTitle": next_display,
"nextArtist": next_artist,
"nextTrackTitle": next_track,
"nextFile": next_file,
"nextTitles": next_titles,
} }
@@ -117,6 +146,8 @@ class SharedState:
"index": -1, "total": 0, "index": -1, "total": 0,
"title": None, "artist": None, "trackTitle": None, "file": None, "title": None, "artist": None, "trackTitle": None, "file": None,
"currentTime": 0, "duration": 0, "paused": True, "currentTime": 0, "duration": 0, "paused": True,
"nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None,
"nextTitles": [],
} }
self.changed = asyncio.Event() self.changed = asyncio.Event()
@@ -134,7 +165,21 @@ async def mpd_loop(state: SharedState):
while True: while True:
status = await client.status() status = await client.status()
song = await client.currentsong() song = await client.currentsong()
state.data = mpd_to_event(status, song) # 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
state.data = mpd_to_event(status, song, next_songs)
state.changed.set() state.changed.set()
await asyncio.sleep(TICK_SECONDS) await asyncio.sleep(TICK_SECONDS)
except Exception as e: except Exception as e: