Files
audioserver/scripts/snapcast-now-playing.py
2026-05-17 00:04:52 +02:00

788 lines
28 KiB
Python
Executable File

#!/usr/bin/env python3
import atexit
import json
import itertools
import os
import subprocess
import tempfile
import threading
import math
import struct
import select
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
try:
import numpy as np
_HAS_NUMPY = True
except ImportError:
_HAS_NUMPY = False
# ── Base configuration from environment ──────────────────────────────────────
SNAPCAST_URL = os.environ.get("SNAPCAST_URL", "http://127.0.0.1:1780/jsonrpc")
MOPIDY_URL = os.environ.get("MOPIDY_URL", "http://127.0.0.1:8180/api/mopidy/rpc")
BEOCREATE_HOST = os.environ.get("BEOCREATE_HOST", "")
BEOCREATE_PORT = os.environ.get("BEOCREATE_PORT", "13141")
HIFIBERRY_CARD = os.environ.get("HIFIBERRY_CARD", "sndrpihifiberry")
SONGREC_BIN = os.environ.get("SONGREC_BIN", "songrec-lib-cli")
ARECORD_BIN = os.environ.get("ARECORD_BIN", "arecord")
CAVA_BIN = os.environ.get("CAVA_BIN", "cava")
CAPTURE_SECONDS = int(os.environ.get("SONGREC_CAPTURE_SECONDS", "12"))
CACHE_TTL = int(os.environ.get("SONGREC_CACHE_TTL", "90"))
CAVA_BARS = int(os.environ.get("CAVA_BARS", "24"))
CAVA_FRAMERATE = int(os.environ.get("CAVA_FRAMERATE", "20"))
AUDIO_PIPES_DIR = os.environ.get("AUDIO_PIPES_DIR", "/home/admin/appdata/audioserver/pipes")
ENV_FILE = os.environ.get("ENV_FILE", "/home/admin/appdata/audioserver/.env")
SETTINGS_FILE = os.environ.get("SETTINGS_FILE", os.path.join(
os.path.dirname(os.path.abspath(__file__)), ".now-playing-settings.json"
))
# ── Runtime-configurable overrides (settable via POST /config) ───────────────
_runtime = {}
_runtime_lock = threading.Lock()
def _cfg(key, default):
with _runtime_lock:
return _runtime.get(key, default)
def effective_snapcast_url():
return _cfg("snapcast_url", SNAPCAST_URL)
def effective_mopidy_url():
return _cfg("mopidy_url", MOPIDY_URL)
def mopidy_base_url():
parsed = urlparse(effective_mopidy_url())
return f"{parsed.scheme}://{parsed.netloc}"
# ── Persistent settings helpers ───────────────────────────────────────────────
def _load_settings():
try:
with open(SETTINGS_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_settings(updates):
existing = _load_settings()
existing.update(updates)
with open(SETTINGS_FILE, "w") as f:
json.dump(existing, f, indent=2)
def _update_env_file(updates):
try:
with open(ENV_FILE) as f:
lines = f.readlines()
except FileNotFoundError:
lines = []
updated = set()
new_lines = []
for line in lines:
stripped = line.strip()
if stripped and not stripped.startswith("#"):
key = stripped.split("=", 1)[0].strip()
if key in updates:
new_lines.append(f"{key}={updates[key]}\n")
updated.add(key)
continue
new_lines.append(line)
for key, value in updates.items():
if key not in updated:
new_lines.append(f"{key}={value}\n")
with open(ENV_FILE, "w") as f:
f.writelines(new_lines)
# ── Internal state ────────────────────────────────────────────────────────────
_rpc_id = itertools.count(1)
_cache = {}
_cache_lock = threading.Lock()
_cava_lock = threading.Lock()
_cava_process = None
_cava_thread = None
_cava_config_path = None
_cava_state = {
"bars": [0] * CAVA_BARS,
"at": 0,
"running": False,
"error": None,
}
_CAVA_SOURCES = {
"mopidy": os.path.join(AUDIO_PIPES_DIR, "cava.fifo"),
"turntable": f"dsnoop:CARD={HIFIBERRY_CARD},DEV=0",
}
_cava_target_source = None
_mopidy_fifo_fd = None
_mopidy_fifo_lock = threading.Lock()
_mopidy_reader_thread = None
_mopidy_stop_event = threading.Event()
# ── JSON-RPC helper ───────────────────────────────────────────────────────────
def rpc(url, method, params=None, timeout=8):
body = json.dumps({
"jsonrpc": "2.0",
"id": next(_rpc_id),
"method": method,
"params": params or {},
}).encode()
req = urllib.request.Request(
url,
data=body,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=timeout) as response:
payload = json.loads(response.read().decode())
if payload.get("error"):
raise RuntimeError(payload["error"].get("message", "RPC error"))
return payload.get("result")
# ── Snapcast / Mopidy metadata ────────────────────────────────────────────────
def snapcast_status(max_age=0.75):
cache_key = ("snapcast_status", effective_snapcast_url())
now = time.time()
with _cache_lock:
cached = _cache.get(cache_key)
if cached and now - cached["at"] <= max_age:
return cached["data"]
result = rpc(effective_snapcast_url(), "Server.GetStatus")
server = result.get("server", result)
groups = server.get("groups", [])
streams = server.get("streams", [])
active_group = next(
(g for g in groups if any(c.get("connected") for c in g.get("clients", []))),
None,
)
active_stream_id = active_group.get("stream_id") if active_group else None
active_stream = next((s for s in streams if s.get("id") == active_stream_id), None)
data = {
"group": active_group,
"stream": active_stream,
"stream_id": active_stream_id,
"streams": streams,
}
with _cache_lock:
_cache[cache_key] = {"at": now, "data": data}
return data
def mopidy_now_playing():
mopidy_url = effective_mopidy_url()
tl_track = rpc(mopidy_url, "core.playback.get_current_tl_track")
if not tl_track:
return None
track = tl_track.get("track") or {}
album = track.get("album") or {}
artists = ", ".join(a.get("name", "") for a in track.get("artists", []) if a.get("name"))
cover = None
image_uris = [u for u in [track.get("uri"), album.get("uri")] if u]
if image_uris:
images = rpc(mopidy_url, "core.library.get_images", {"uris": image_uris})
image = (images.get(track.get("uri"), []) or images.get(album.get("uri"), []) or [None])[0]
if image and image.get("uri"):
path = image["uri"].lstrip("/").removeprefix("mopidy/")
cover = f"/api/mopidy-image/{path}"
return {
"source": "mopidy",
"title": track.get("name"),
"artist": artists,
"album": album.get("name"),
"artwork": cover,
"uri": track.get("uri"),
}
# ── SongRec normalizer ────────────────────────────────────────────────────────
def metadata_value(track, key):
for section in track.get("sections", []):
for item in section.get("metadata", []):
if item.get("title", "").lower() == key.lower():
return item.get("text")
return None
def normalize_songrec(payload):
track = payload.get("track") if isinstance(payload, dict) else None
if not track and isinstance(payload, dict):
track = payload.get("result", {}).get("track")
if not track and isinstance(payload, dict):
track = payload.get("raw_response", {}).get("track")
if isinstance(payload, dict) and ("title" in payload or "song_name" in payload):
images = (track or {}).get("images", {})
return {
"source": "songrec",
"title": payload.get("title") or payload.get("song_name"),
"artist": payload.get("artist") or payload.get("artist_name"),
"album": payload.get("album") or payload.get("album_name"),
"genre": payload.get("genre"),
"released": payload.get("release_year"),
"artwork": (
payload.get("artwork")
or payload.get("cover_album_url")
or images.get("coverarthq")
or images.get("coverart")
or images.get("background")
),
"shazam_url": (track or {}).get("url"),
}
if not track:
return {"source": "songrec", "raw": payload}
images = track.get("images", {})
return {
"source": "songrec",
"title": track.get("title"),
"artist": track.get("subtitle"),
"album": metadata_value(track, "Album"),
"genre": metadata_value(track, "Genre"),
"label": metadata_value(track, "Label"),
"released": metadata_value(track, "Released"),
"artwork": images.get("coverarthq") or images.get("coverart") or images.get("background"),
"shazam_url": track.get("url"),
}
def run_songrec_for_turntable():
now = time.time()
cached = _cache.get("turntable")
if cached and now - cached["at"] < CACHE_TTL:
return cached["data"]
capture_secs = int(_cfg("capture_seconds", CAPTURE_SECONDS))
device = f"plug:'dsnoop:CARD={HIFIBERRY_CARD},DEV=0'"
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as sample:
sample_path = sample.name
try:
subprocess.run(
[
ARECORD_BIN,
"--device", device,
"--duration", str(capture_secs),
"--rate", "48000",
"--channels", "2",
"--format", "S16_LE",
"--file-type", "wav",
sample_path,
],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=capture_secs + 5,
)
result = subprocess.run(
[SONGREC_BIN, "recognize", "--format", "json", sample_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=35,
)
if result.returncode != 0:
message = result.stderr or result.stdout or "Recognition failed"
if "No track found" in message:
return {
"source": "songrec",
"title": None,
"artist": None,
"album": None,
"artwork": None,
"message": "No match",
"recognized_at": int(now),
}
raise RuntimeError(message.strip())
data = normalize_songrec(json.loads(result.stdout))
data["recognized_at"] = int(now)
_cache["turntable"] = {"at": now, "data": data}
return data
finally:
try:
os.unlink(sample_path)
except FileNotFoundError:
pass
# ── CAVA source helper ────────────────────────────────────────────────────────
def cava_source():
return os.environ.get("CAVA_SOURCE", f"dsnoop:CARD={HIFIBERRY_CARD},DEV=0")
# ── Python FFT analyzer (for mopidy FIFO) ────────────────────────────────────
FFT_SIZE = 1024
FFT_SAMPLE_RATE = 48000
def _radix2_fft(x):
n = len(x)
if n <= 1:
return x
even = _radix2_fft(x[0::2])
odd = _radix2_fft(x[1::2])
T = [complex(math.cos(-2 * math.pi * k / n), math.sin(-2 * math.pi * k / n)) * odd[k]
for k in range(n // 2)]
return [even[k] + T[k] for k in range(n // 2)] + [even[k] - T[k] for k in range(n // 2)]
def _compute_levels(samples):
n = len(samples)
fft_len = 1 << (n - 1).bit_length()
if _HAS_NUMPY:
magnitudes = list(np.abs(np.fft.rfft(samples, n=fft_len))[:fft_len // 2])
else:
padded = list(samples) + [0] * (fft_len - n)
try:
spectrum = _radix2_fft(padded)
except Exception:
return [0] * CAVA_BARS
magnitudes = [abs(s) for s in spectrum[:fft_len // 2]]
low_bin = 1
high_bin = fft_len // 4
bands = []
for i in range(CAVA_BARS):
freq = 20 * (20000 / 20) ** (i / CAVA_BARS)
next_freq = 20 * (20000 / 20) ** ((i + 1) / CAVA_BARS)
start = max(low_bin, min(int(freq * fft_len / FFT_SAMPLE_RATE), high_bin))
end = min(int(next_freq * fft_len / FFT_SAMPLE_RATE) + 1, high_bin + 1)
band_energy = sum(magnitudes[start:end]) / (end - start) if end > start else 0
bands.append(band_energy)
if bands:
max_val = max(bands) or 1
bands = [min(100, max(0, int(b / max_val * 100))) for b in bands]
else:
bands = [0] * CAVA_BARS
return bands
def _open_mopidy_fifo():
global _mopidy_fifo_fd
fifo_path = os.path.join(AUDIO_PIPES_DIR, "cava.fifo")
try:
fd = os.open(fifo_path, os.O_RDONLY | os.O_NONBLOCK)
_mopidy_fifo_fd = fd
return fd
except OSError:
_mopidy_fifo_fd = None
return None
def _mopidy_fifo_reader():
global _mopidy_fifo_fd
chunk_size = FFT_SIZE * 4 # Stereo S16LE: 4 bytes per frame
buf = bytearray()
while not _mopidy_stop_event.is_set():
try:
if _mopidy_fifo_fd is None:
_mopidy_fifo_fd = _open_mopidy_fifo()
if _mopidy_fifo_fd is None:
time.sleep(0.5)
continue
ready = select.select([_mopidy_fifo_fd], [], [], 0.5)
if not ready[0]:
continue
data = os.read(_mopidy_fifo_fd, chunk_size)
if not data:
# Writer (mopidy) disconnected → EOF.
# DO NOT close the read fd: keeping it open lets GStreamer
# open the write end without blocking when a new writer
# reconnects. Just sleep and wait for the next writer.
time.sleep(0.1)
continue
buf.extend(data)
while len(buf) >= chunk_size:
chunk = bytes(buf[:chunk_size])
buf = buf[chunk_size:]
# Downmix stereo S16LE to mono
stereo = struct.unpack(f"<{FFT_SIZE * 2}h", chunk)
samples = [(stereo[i] + stereo[i + 1]) / 2 / 32768.0
for i in range(0, len(stereo), 2)]
levels = _compute_levels(samples)
with _cava_lock:
_cava_state["bars"] = levels
_cava_state["at"] = time.time()
_cava_state["running"] = True
_cava_state["error"] = None
except Exception as exc:
with _cava_lock:
_cava_state["running"] = False
_cava_state["error"] = str(exc)
time.sleep(0.5)
# ── CAVA process (turntable source) ──────────────────────────────────────────
def write_cava_config():
config = f"""
[general]
bars = {CAVA_BARS}
framerate = {CAVA_FRAMERATE}
autosens = 1
sensitivity = 100
[input]
method = alsa
source = {cava_source()}
[output]
method = raw
raw_target = /dev/stdout
data_format = ascii
ascii_max_range = 100
bar_delimiter = 59
frame_delimiter = 10
""".strip()
handle = tempfile.NamedTemporaryFile("w", prefix="snapcast-cava-", suffix=".conf", delete=False)
with handle:
handle.write(config + "\n")
return handle.name
def parse_cava_frame(line):
try:
parts = line.decode("utf-8", errors="ignore").strip().split(";")
values = [max(0, min(100, int(p))) for p in parts if p.strip()]
except ValueError:
return None
if not values:
return None
if len(values) < CAVA_BARS:
values.extend([0] * (CAVA_BARS - len(values)))
return values[:CAVA_BARS]
def cava_reader(process, config_path):
global _cava_process
error = None
try:
for line in iter(process.stdout.readline, b""):
values = parse_cava_frame(line)
if not values:
continue
with _cava_lock:
_cava_state["bars"] = values
_cava_state["at"] = time.time()
_cava_state["running"] = True
_cava_state["error"] = None
except Exception as exc:
error = str(exc)
finally:
stderr = ""
try:
if process.stderr:
stderr = process.stderr.read().decode("utf-8", errors="ignore").strip()
except Exception:
pass
with _cava_lock:
if _cava_process is process:
_cava_process = None
_cava_state["running"] = False
_cava_state["error"] = error or stderr or f"cava exited with {process.poll()}"
# Clean up temp config file
try:
os.unlink(config_path)
except Exception:
pass
# ── Source detection + CAVA lifecycle ────────────────────────────────────────
def get_active_source():
try:
status = snapcast_status()
except Exception:
return None
stream_id = status.get("stream_id")
if stream_id in ("mopidy", "turntable"):
return stream_id
return None
def ensure_cava():
global _cava_process, _cava_thread, _cava_config_path
global _cava_target_source, _mopidy_reader_thread, _mopidy_fifo_fd
active = get_active_source()
with _cava_lock:
if active != _cava_target_source:
if _cava_process:
_cava_process.terminate()
try:
_cava_process.wait(timeout=2)
except Exception:
_cava_process.kill()
_cava_process = None
_cava_thread = None
_cava_state["running"] = False
_cava_state["error"] = None
_cava_target_source = active
with _cava_lock:
if active == "mopidy":
# The reader thread is always running globally (started at service init)
_cava_state["running"] = True
_cava_state["error"] = None
elif active == "turntable":
if _cava_process is None or _cava_process.poll() is not None:
_cava_config_path = write_cava_config()
_cava_process = subprocess.Popen(
[CAVA_BIN, "-p", _cava_config_path],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL,
)
_cava_thread = threading.Thread(
target=cava_reader,
args=(_cava_process, _cava_config_path),
daemon=True,
)
_cava_thread.start()
_cava_state["running"] = True
_cava_state["error"] = None
else:
_cava_state["running"] = False
_cava_state["error"] = None
def visualizer():
ensure_cava()
now = time.time()
active = get_active_source()
with _cava_lock:
bars = list(_cava_state["bars"])
updated_at = _cava_state["at"]
running = _cava_state["running"]
error = _cava_state["error"]
if updated_at and now - updated_at > 3:
bars = [0] * CAVA_BARS
source_input = _CAVA_SOURCES.get(active, cava_source())
return {
"source": active or "unknown",
"input": source_input,
"bars": bars,
"running": running,
"updated_at": int(updated_at) if updated_at else None,
"stale": bool(updated_at and now - updated_at > 3),
"error": error,
}
# ── Now-playing aggregator ────────────────────────────────────────────────────
def artwork_response():
data = now_playing(False)
artwork = (data.get("metadata") or {}).get("artwork")
if not artwork:
return None
url = artwork
if artwork.startswith("/"):
url = f"{mopidy_base_url()}{artwork}"
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None
req = urllib.request.Request(url, headers={"User-Agent": "audioserver-now-playing/1.0"})
with urllib.request.urlopen(req, timeout=10) as response:
content_type = response.headers.get("Content-Type", "image/jpeg")
return content_type, response.read()
def now_playing(force_recognize=False):
status = snapcast_status()
stream_id = status.get("stream_id")
stream = status.get("stream") or {}
data = {
"active_stream": stream_id,
"stream_status": stream.get("status"),
"metadata": None,
}
if stream_id == "mopidy":
data["metadata"] = mopidy_now_playing()
elif stream_id == "turntable":
cached = _cache.get("turntable")
cache_stale = not cached or time.time() - cached["at"] >= CACHE_TTL
if force_recognize or cache_stale:
data["metadata"] = run_songrec_for_turntable()
else:
data["metadata"] = cached["data"]
return data
# ── HTTP handler ──────────────────────────────────────────────────────────────
class Handler(BaseHTTPRequestHandler):
def send_json(self, status, payload):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_bytes(self, status, content_type, body):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
try:
path = self.path.split("?", 1)[0]
if path == "/health":
self.send_json(200, {"ok": True})
elif path == "/now-playing":
self.send_json(200, now_playing(False))
elif path == "/recognize":
self.send_json(200, now_playing(True))
elif path == "/visualizer":
self.send_json(200, visualizer())
elif path == "/artwork":
artwork = artwork_response()
if artwork is None:
self.send_json(404, {"error": "no artwork"})
else:
self.send_bytes(200, artwork[0], artwork[1])
elif path == "/config":
with _runtime_lock:
rt = dict(_runtime)
self.send_json(200, {
"snapcast_url": rt.get("snapcast_url", SNAPCAST_URL),
"mopidy_url": rt.get("mopidy_url", MOPIDY_URL),
"beocreate_host": rt.get("beocreate_host", BEOCREATE_HOST),
"beocreate_port": rt.get("beocreate_port", BEOCREATE_PORT),
"capture_seconds": int(rt.get("capture_seconds", CAPTURE_SECONDS)),
"cava_bars": int(rt.get("cava_bars", CAVA_BARS)),
"hifiberry_card": rt.get("hifiberry_card", HIFIBERRY_CARD),
"audio_pipes_dir": rt.get("audio_pipes_dir", AUDIO_PIPES_DIR),
})
else:
self.send_json(404, {"error": "not found"})
except FileNotFoundError as exc:
self.send_json(503, {"error": f"Missing executable: {exc.filename}"})
except subprocess.CalledProcessError as exc:
self.send_json(502, {"error": exc.stderr or exc.stdout or str(exc)})
except subprocess.TimeoutExpired as exc:
cmd = exc.cmd[0] if isinstance(exc.cmd, list) else str(exc.cmd)
self.send_json(504, {"error": f"Timed out: {os.path.basename(cmd)}"})
except (urllib.error.URLError, RuntimeError, TimeoutError, json.JSONDecodeError) as exc:
self.send_json(502, {"error": str(exc)})
def do_POST(self):
try:
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length).decode()) if length else {}
path = self.path.split("?", 1)[0]
if path == "/config":
self._handle_post_config(body)
else:
self.send_json(404, {"error": "not found"})
except (json.JSONDecodeError, ValueError) as exc:
self.send_json(400, {"error": str(exc)})
except Exception as exc:
self.send_json(500, {"error": str(exc)})
def _handle_post_config(self, body):
# Keys applied in-memory immediately (no service restart needed)
RUNTIME_KEYS = {"snapcast_url", "mopidy_url", "capture_seconds", "cava_bars"}
# Keys that live in .env and affect nginx (require nginx restart)
ENV_MAP = {
"beocreate_host": "BEOCREATE_HOST",
"beocreate_port": "BEOCREATE_PORT",
}
runtime_updates = {k: body[k] for k in RUNTIME_KEYS if k in body}
with _runtime_lock:
_runtime.update(runtime_updates)
_save_settings(body)
env_updates = {}
for cfg_key, env_key in ENV_MAP.items():
if cfg_key in body:
env_updates[env_key] = str(body[cfg_key])
restart_required = []
if env_updates:
_update_env_file(env_updates)
restart_required.append("nginx")
self.send_json(200, {
"ok": True,
"applied": list(runtime_updates.keys()) + list(env_updates.keys()),
"restart_required": restart_required,
})
def log_message(self, fmt, *args):
print("[now-playing] " + fmt % args)
# ── Cleanup on exit ───────────────────────────────────────────────────────────
@atexit.register
def _cleanup():
if _cava_process:
try:
_cava_process.terminate()
except Exception:
pass
if _cava_config_path:
try:
os.unlink(_cava_config_path)
except Exception:
pass
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Apply any previously saved settings
saved = _load_settings()
if saved:
with _runtime_lock:
_runtime.update(saved)
port = int(os.environ.get("NOW_PLAYING_PORT", "8090"))
host = os.environ.get("NOW_PLAYING_HOST", "0.0.0.0")
# Always start the mopidy FIFO reader at startup to prevent GStreamer
# from blocking on filesink when it tries to write to cava.fifo
_mopidy_reader_thread = threading.Thread(
target=_mopidy_fifo_reader, daemon=True
)
_mopidy_reader_thread.start()
_cava_state["running"] = True
_cava_state["error"] = None
server = ThreadingHTTPServer((host, port), Handler)
print(f"[now-playing] listening on {host}:{port}")
if _HAS_NUMPY:
print("[now-playing] FFT backend: numpy")
else:
print("[now-playing] FFT backend: pure Python (install numpy for better performance)")
server.serve_forever()