From 70a5f64871dcd0a46a0021ca0c773d5bc8cbf042 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 27 Apr 2026 03:44:54 +0200 Subject: [PATCH] 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. --- bridges/mpd-state.py | 71 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/bridges/mpd-state.py b/bridges/mpd-state.py index 8edadcf..b1f87db 100755 --- a/bridges/mpd-state.py +++ b/bridges/mpd-state.py @@ -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: