777 lines
28 KiB
Python
Executable File
777 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 = {}
|
|
_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():
|
|
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)
|
|
return {
|
|
"group": active_group,
|
|
"stream": active_stream,
|
|
"stream_id": active_stream_id,
|
|
"streams": streams,
|
|
}
|
|
|
|
|
|
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()
|