#!/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()