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
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
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
def mpd_to_event(status: dict, song: dict) -> dict:
"""Translate `status` + `currentsong` into the overlay's expected payload.
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)
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"))
@@ -98,6 +98,30 @@ def mpd_to_event(status: dict, song: dict) -> dict:
else:
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 {
"index": int(status["song"]) if "song" in status else -1,
"total": int(status.get("playlistlength") or 0),
@@ -108,6 +132,11 @@ def mpd_to_event(status: dict, song: dict) -> dict:
"currentTime": elapsed,
"duration": duration,
"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,
"title": None, "artist": None, "trackTitle": None, "file": None,
"currentTime": 0, "duration": 0, "paused": True,
"nextTitle": None, "nextArtist": None, "nextTrackTitle": None, "nextFile": None,
"nextTitles": [],
}
self.changed = asyncio.Event()
@@ -134,7 +165,21 @@ async def mpd_loop(state: SharedState):
while True:
status = await client.status()
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()
await asyncio.sleep(TICK_SECONDS)
except Exception as e: