Codex. Cover support, updates for Music Box
This commit is contained in:
@@ -9,14 +9,35 @@ 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,
|
||||
nextTitle, nextArtist, nextTrackTitle, nextFile, nextTitles }
|
||||
{ _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).
|
||||
|
||||
@@ -41,7 +62,12 @@ 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 ──────────────────────────────────────────────────────────
|
||||
@@ -101,7 +127,66 @@ def _song_display(song: dict) -> tuple:
|
||||
return display, artist, track_name
|
||||
|
||||
|
||||
def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> dict:
|
||||
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."""
|
||||
@@ -110,6 +195,23 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di
|
||||
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
|
||||
@@ -128,6 +230,9 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di
|
||||
"title": display,
|
||||
"artist": artist,
|
||||
"trackTitle": track_name,
|
||||
"album": album,
|
||||
"year": year,
|
||||
"genre": genre,
|
||||
"file": song.get("file"),
|
||||
"currentTime": elapsed,
|
||||
"duration": duration,
|
||||
@@ -137,6 +242,10 @@ def mpd_to_event(status: dict, song: dict, next_songs: list | None = None) -> di
|
||||
"nextTrackTitle": next_track,
|
||||
"nextFile": next_file,
|
||||
"nextTitles": next_titles,
|
||||
"coverPath": cover_path,
|
||||
"coverHash": cover_hash,
|
||||
"playback": playback,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
|
||||
@@ -144,14 +253,145 @@ class SharedState:
|
||||
def __init__(self):
|
||||
self.data = {
|
||||
"index": -1, "total": 0,
|
||||
"title": None, "artist": None, "trackTitle": None, "file": None,
|
||||
"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):
|
||||
@@ -179,7 +419,10 @@ async def mpd_loop(state: SharedState):
|
||||
next_songs.append(rows[0])
|
||||
except Exception:
|
||||
break # transient — let it retry next tick
|
||||
state.data = mpd_to_event(status, song, next_songs)
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user