#!/usr/bin/env python3 import json import socket import os import signal import subprocess import sys import time from pathlib import Path from typing import Optional, Dict, Any import threading import paho.mqtt.client as mqtt import yaml import jack # ---------------------------- # Config + paths # ---------------------------- BASE_DIR = Path(__file__).resolve().parent CONFIG_PATH = BASE_DIR / "config.yaml" config = yaml.safe_load(CONFIG_PATH.read_text()) MEDIA_BASE = os.environ.get("YTPLAYER_MEDIA_BASE", "/mnt/Media/YTPlayer") MQTT_HOST = config["mqtt"]["host"] MQTT_PORT = config["mqtt"].get("port", 1883) MQTT_USER = config["mqtt"].get("username") MQTT_PASS = config["mqtt"].get("password") CLIENT_ID_PREFIX = config["mqtt"].get("client_id_prefix", "ytplayer") HA_DISCOVERY_PREFIX = config.get("homeassistant", {}).get("discovery_prefix", "homeassistant") HA_DEVICE_ID = config.get("homeassistant", {}).get("device_id", "ytplayer") HA_DEVICE_NAME = config.get("homeassistant", {}).get("device_name", "YT Player") TOPIC_CMD = config["topics"]["cmd"] # e.g. "youtube/player/cmd" TOPIC_STATUS = config["topics"]["status"] # e.g. "youtube/player/status" TOPIC_STATE = "youtube/player/state" # simple string state for Companion MPV_BIN = config["player"].get("mpv_binary", "/usr/bin/mpv") DISPLAY = config["player"].get("display", ":0") XDG_RUNTIME_DIR = config["player"].get("xdg_runtime_dir", "/run/user/1000") TELEMETRY_TOPIC = config.get("topics", {}).get("telemetry", "youtube/player/telemetry") MPV_SOCKET = config["player"].get("ipc_socket", "/tmp/mpvsocket") TELEMETRY_INTERVAL = float(config["player"].get("telemetry_interval", 0.5)) # seconds VIDEO_AUDIO_CONNECT_DELAY = float(config["player"].get("audio_connect_delay", 1.0)) VIDEO_HDMI_L = config["video_audio"]["hdmi"]["left"] VIDEO_HDMI_R = config["video_audio"]["hdmi"]["right"] VIDEO_ANALOG_L = config["video_audio"]["analog"]["left"] VIDEO_ANALOG_R = config["video_audio"]["analog"]["right"] # ---------------------------- # Runtime state # ---------------------------- mpv_proc: Optional[subprocess.Popen] = None current_path: Optional[str] = None current_slot: Optional[int] = None busy_until: Optional[float] = None mpv_listener_thread: Optional["threading.Thread"] = None mpv_listener_stop = None # will become threading.Event() client = mqtt.Client( mqtt.CallbackAPIVersion.VERSION2, client_id=f"{CLIENT_ID_PREFIX}-player", clean_session=True, ) # ---------------------------- # MQTT helpers # ---------------------------- def publish_status(state: str, **extra: Any) -> None: """ Publish: - JSON status (for HA / controller) to TOPIC_STATUS - Flat string (for Companion color mapping) to TOPIC_STATE """ payload: Dict[str, Any] = {"state": state} if current_path: payload["path"] = current_path if current_slot is not None: payload["slot"] = current_slot payload.update(extra) # JSON status client.publish(TOPIC_STATUS, json.dumps(payload), qos=1, retain=False) # Flat state string client.publish(TOPIC_STATE, state, qos=0, retain=True) def ha_discovery_sensor( entity_id: str, name: str, value_template: str, icon: str = "", unit: str = "", ) -> None: topic = f"{HA_DISCOVERY_PREFIX}/sensor/{HA_DEVICE_ID}/{entity_id}/config" payload: Dict[str, Any] = { "name": name, "unique_id": f"{HA_DEVICE_ID}_{entity_id}", "state_topic": TELEMETRY_TOPIC, "value_template": value_template, "availability_topic": TOPIC_STATUS, "availability_template": "{% if value_json.state != 'offline' %}online{% else %}offline{% endif %}", "payload_available": "online", "payload_not_available": "offline", "device": { "identifiers": [HA_DEVICE_ID], "name": HA_DEVICE_NAME, "manufacturer": "Custom", "model": "mpv mqtt player", }, } if icon: payload["icon"] = icon if unit: payload["unit_of_measurement"] = unit client.publish(topic, json.dumps(payload), qos=1, retain=True) # ---------------------------- # MPV control # ---------------------------- def stop_mpv(reason: str = "stop") -> None: """ Stop mpv if running, reset state, publish idle. Important: we keep the slot around long enough so the controller can see which slot just went idle/finished. """ global mpv_proc, current_path, current_slot global mpv_listener_thread, mpv_listener_stop if mpv_listener_stop is not None: mpv_listener_stop.set() mpv_listener_thread = None mpv_listener_stop = None slot_before = current_slot if mpv_proc and mpv_proc.poll() is None: try: mpv_proc.terminate() try: mpv_proc.wait(timeout=5) except subprocess.TimeoutExpired: mpv_proc.kill() except Exception as e: # still publish error, with whatever slot we had current_slot = slot_before publish_status("error", message=f"Failed to stop mpv: {e}") current_slot = None mpv_proc = None current_path = None # clear telemetry so HA doesn't keep stale values publish_cleared_telemetry() # temporarily restore slot so status includes it current_slot = slot_before publish_status("idle", reason=reason) current_slot = None def handle_play(cmd): """ cmd is a dict, expected keys: - action: "play" - path: absolute path to .m3u (optional) - playlist: optional explicit playlist path - slot: optional int (1..N) – controller usually sends this - label/title: optional """ global mpv_proc, current_path, current_slot, busy_until print(f"[player] handle_play cmd={cmd!r}") path = cmd.get("path") playlist = cmd.get("playlist") slot = cmd.get("slot") label = cmd.get("label") or cmd.get("title") # Decide what we’re actually going to play final_path = None # 1) explicit path wins if path: final_path = path # 2) explicit playlist from controller elif playlist: final_path = playlist # 3) derive from slot: /mnt/Media/YTPlayer/slotN/playlist.m3u elif slot is not None: try: slot_num = int(slot) except (TypeError, ValueError): print(f"[player] bad slot value: {slot!r}") publish_status("error", message=f"bad slot value: {slot!r}") return final_path = f"{MEDIA_BASE}/slot{slot_num}/playlist.m3u" if not final_path: print("[player] no valid 'path', 'playlist' or 'slot' supplied") publish_status("error", message="play command missing path/playlist/slot") return final_path = os.path.abspath(final_path) if not os.path.exists(final_path): print(f"[player] path not found: {final_path}") publish_status("error", message=f"path not found: {final_path}") return # enforce “must be stopped before new playlist” if mpv_proc and mpv_proc.poll() is None: print("[player] already playing, sending busy") publish_status("busy", message="mpv already playing; stop first") busy_until = time.time() + 5 return args = [ MPV_BIN, f"--input-ipc-server={MPV_SOCKET}", "--audio-client-name=MPV", ] if final_path.endswith(".m3u"): # mpv wants --playlist=/path/to/file, not "--playlist" "/path" args.append(f"--playlist={final_path}") else: args.append(final_path) env = os.environ.copy() env["DISPLAY"] = DISPLAY env["XDG_RUNTIME_DIR"] = XDG_RUNTIME_DIR try: print(f"[player] starting mpv: {args!r}") mpv_proc = subprocess.Popen( args, env=env, stdout=None, stderr=None, ) threading.Thread(target=connect_mpv_audio, daemon=True).start() current_path = final_path try: current_slot_int = int(slot) if slot is not None else None except (TypeError, ValueError): current_slot_int = None current_slot = current_slot_int publish_status("playing", label=label) # start telemetry observer thread global mpv_listener_thread, mpv_listener_stop mpv_listener_stop = threading.Event() mpv_listener_thread = threading.Thread(target=mpv_observe_properties, daemon=True) mpv_listener_thread.start() except Exception as e: print(f"[player] failed to start mpv: {e}") mpv_proc = None current_path = None current_slot = None publish_status("error", message=f"failed to start mpv: {e}") def mpv_ipc(command): """Send a JSON IPC command to MPV via MPV_SOCKET.""" try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) # Wait for socket to appear and accept connections deadline = time.time() + 3.0 while True: try: sock.connect(MPV_SOCKET) break except FileNotFoundError: if time.time() > deadline: raise time.sleep(0.05) except ConnectionRefusedError: if time.time() > deadline: raise time.sleep(0.05) sock.sendall((json.dumps({"command": command}) + "\n").encode("utf-8")) sock.close() return True except Exception as e: print(f"[player] mpv_ipc error: {e}") return False def fmt_mmss(value: Optional[float]) -> Optional[str]: if not isinstance(value, (int, float)) or value < 0: return None total = int(value) s = total % 60 m = (total // 60) % 60 h = total // 3600 if h > 0: return f"{h}:{m:02d}:{s:02d}" return f"{m}:{s:02d}" def publish_telemetry( *, pos: Optional[float], dur: Optional[float], paused: Optional[bool], title: Optional[str], filename: Optional[str], playlist_pos: Optional[int], playlist_count: Optional[int], ) -> None: rem = None if isinstance(pos, (int, float)) and isinstance(dur, (int, float)): rem = max(0.0, float(dur) - float(pos)) payload: Dict[str, Any] = { "state": "telemetry", "slot": current_slot, "path": current_path, # raw seconds "position": pos, "duration": dur, "remaining": rem, # formatted strings "position_fmt": fmt_mmss(pos), "duration_fmt": fmt_mmss(dur), "remaining_fmt": fmt_mmss(rem), "paused": paused, "title": title, "filename": filename, "playlist_pos": playlist_pos, "playlist_count": playlist_count, "ts": time.time(), } client.publish(TELEMETRY_TOPIC, json.dumps(payload), qos=0, retain=False) def publish_cleared_telemetry() -> None: payload: Dict[str, Any] = { "state": "telemetry", "slot": current_slot, "path": None, "position": 0, "duration": 0, "remaining": 0, "position_fmt": "0:00", "duration_fmt": "0:00", "remaining_fmt": "0:00", "paused": False, "title": None, "filename": None, "playlist_pos": None, "playlist_count": None, "ts": time.time(), } client.publish(TELEMETRY_TOPIC, json.dumps(payload), qos=0, retain=False) def mpv_observe_properties() -> None: """ Keep a persistent IPC connection to mpv and observe properties. Publishes telemetry to MQTT at TELEMETRY_INTERVAL cadence. """ global mpv_listener_stop # local cache pos: Optional[float] = None dur: Optional[float] = None paused: Optional[bool] = None title: Optional[str] = None filename: Optional[str] = None playlist_pos: Optional[int] = None playlist_count: Optional[int] = None last_pub = 0.0 try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) deadline = time.time() + 3.0 while True: try: sock.connect(MPV_SOCKET) break except (FileNotFoundError, ConnectionRefusedError): if time.time() > deadline: raise time.sleep(0.05) sock.settimeout(1.0) def send(cmd): sock.sendall((json.dumps({"command": cmd}) + "\n").encode("utf-8")) # Observe useful properties send(["observe_property", 1, "time-pos"]) # float seconds send(["observe_property", 2, "duration"]) # float seconds send(["observe_property", 3, "pause"]) # bool send(["observe_property", 4, "media-title"]) # str (nice) send(["observe_property", 5, "filename"]) # str send(["observe_property", 6, "playlist-pos"]) # int send(["observe_property", 7, "playlist-count"]) # int print(f"[player] telemetry connected to mpv socket {MPV_SOCKET}") publish_telemetry( pos=pos, dur=dur, paused=paused, title=title, filename=filename, playlist_pos=playlist_pos, playlist_count=playlist_count ) while mpv_listener_stop is not None and not mpv_listener_stop.is_set(): try: chunk = sock.recv(65536) if not chunk: break for line in chunk.decode("utf-8", errors="ignore").splitlines(): if not line.strip(): continue try: evt = json.loads(line) except json.JSONDecodeError: continue if evt.get("event") != "property-change": continue name = evt.get("name") val = evt.get("data") if name == "time-pos": pos = float(val) if isinstance(val, (int, float)) else None elif name == "duration": dur = float(val) if isinstance(val, (int, float)) else None elif name == "pause": paused = bool(val) if isinstance(val, bool) else None elif name == "media-title": title = str(val) if isinstance(val, str) else None elif name == "filename": filename = str(val) if isinstance(val, str) else None elif name == "playlist-pos": playlist_pos = int(val) if isinstance(val, (int, float)) else None elif name == "playlist-count": playlist_count = int(val) if isinstance(val, (int, float)) else None now = time.time() if now - last_pub >= TELEMETRY_INTERVAL: publish_telemetry( pos=pos, dur=dur, paused=paused, title=title, filename=filename, playlist_pos=playlist_pos, playlist_count=playlist_count, ) last_pub = now except socket.timeout: # still periodically publish even if no new events now = time.time() if now - last_pub >= TELEMETRY_INTERVAL: publish_telemetry( pos=pos, dur=dur, paused=paused, title=title, filename=filename, playlist_pos=playlist_pos, playlist_count=playlist_count, ) last_pub = now continue except Exception as e: print(f"[player] mpv_observe_properties error: {e}") finally: try: sock.close() except Exception: pass def connect_mpv_audio(): deadline = time.time() + 8 while time.time() < deadline: try: jack_client = jack.Client("MPVAutoRouter", no_start_server=True) with jack_client: ports = [p.name for p in jack_client.get_ports()] left_candidates = [ p for p in ports if ( p.startswith("MPV:") or p.startswith("mpv:") ) and ( "output_FL" in p or "out_0" in p or "output_0" in p ) ] right_candidates = [ p for p in ports if ( p.startswith("MPV:") or p.startswith("mpv:") ) and ( "output_FR" in p or "out_1" in p or "output_1" in p ) ] if left_candidates and right_candidates: mpv_l = left_candidates[0] mpv_r = right_candidates[0] routes = [ (mpv_l, VIDEO_HDMI_L), (mpv_r, VIDEO_HDMI_R), (mpv_l, VIDEO_ANALOG_L), (mpv_r, VIDEO_ANALOG_R), ] for src, dst in routes: try: jack_client.connect(src, dst) print(f"[player] connected video audio: {src} -> {dst}") except jack.JackError as e: print(f"[player] route skipped/failed {src} -> {dst}: {e}") return print("[player] waiting for MPV audio ports...") except Exception as e: print(f"[player] connect_mpv_audio retry error: {e}") time.sleep(0.25) print("[player] mpv audio ports not found after timeout") # ---------------------------- # Command dispatch # ---------------------------- def handle_cmd(payload_str: str) -> None: """Decode JSON and route to play/stop/etc.""" try: data = json.loads(payload_str or "{}") except json.JSONDecodeError: print(f"[player] bad JSON in cmd: {payload_str!r}") return action = data.get("action") if not action: print("[player] cmd missing 'action'") return print(f"[player] cmd received: {data!r}") if action == "play": cmd = { "action": "play", "path": data.get("path"), "playlist": data.get("playlist"), "slot": data.get("slot"), "label": data.get("label") or data.get("title"), } handle_play(cmd) return if action == "stop": print("[player] STOP command") stop_mpv("stop") return elif action == "pause": if mpv_proc and mpv_proc.poll() is None: mpv_ipc(["set_property", "pause", True]) publish_status("paused") else: publish_status("error", message="nothing playing to pause") elif action == "resume": if mpv_proc and mpv_proc.poll() is None: mpv_ipc(["set_property", "pause", False]) publish_status("playing") else: publish_status("error", message="nothing playing to resume") elif action == "toggle": if mpv_proc and mpv_proc.poll() is None: mpv_ipc(["cycle", "pause"]) publish_status("toggled") else: publish_status("error", message="nothing playing to toggle") print(f"[player] unknown action: {action!r}") # ---------------------------- # MQTT callbacks # ---------------------------- def on_connect(client, userdata, flags, rc, properties=None): if rc == 0: print(f"[player] MQTT connected rc={rc}, subscribing {TOPIC_CMD}") client.subscribe(TOPIC_CMD, qos=1) # initial idle publish_status("idle", message="player online") # Home Assistant MQTT Discovery sensors ha_discovery_sensor("position", "YT Position (s)", "{{ value_json.position }}", icon="mdi:timeline-clock", unit="s") ha_discovery_sensor("duration", "YT Duration (s)", "{{ value_json.duration }}", icon="mdi:timer", unit="s") ha_discovery_sensor("remaining", "YT Remaining (s)", "{{ value_json.remaining }}", icon="mdi:timer-sand", unit="s") ha_discovery_sensor("position_fmt", "YT Position", "{{ value_json.position_fmt }}", icon="mdi:timeline-clock") ha_discovery_sensor("duration_fmt", "YT Duration", "{{ value_json.duration_fmt }}", icon="mdi:timer") ha_discovery_sensor("remaining_fmt", "YT Remaining", "{{ value_json.remaining_fmt }}", icon="mdi:timer-sand") ha_discovery_sensor("title", "YT Title", "{{ value_json.title }}", icon="mdi:music") ha_discovery_sensor("paused", "YT Paused", "{{ value_json.paused }}", icon="mdi:pause-circle") ha_discovery_sensor("slot", "YT Slot", "{{ value_json.slot }}", icon="mdi:playlist-play") else: sys.stderr.write(f"[player] MQTT connect failed with code {rc}\n") def on_message(client, userdata, msg): if msg.topic == TOPIC_CMD: payload = msg.payload.decode("utf-8", errors="ignore") handle_cmd(payload) # ---------------------------- # Signal handling # ---------------------------- def sigterm_handler(signum, frame): print("[player] SIGTERM received, stopping mpv") stop_mpv(reason="sigterm") sys.exit(0) # ---------------------------- # Main loop # ---------------------------- def main() -> None: global mpv_proc, busy_until signal.signal(signal.SIGTERM, sigterm_handler) signal.signal(signal.SIGINT, sigterm_handler) if MQTT_USER: client.username_pw_set(MQTT_USER, MQTT_PASS or None) client.on_connect = on_connect client.on_message = on_message client.will_set( TOPIC_STATUS, json.dumps({"state": "offline"}), qos=1, retain=False, ) print(f"[player] connecting MQTT to {MQTT_HOST}:{MQTT_PORT}") client.connect(MQTT_HOST, MQTT_PORT, keepalive=30) client.loop_start() try: while True: now = time.time() # Watch mpv exit if mpv_proc is not None: ret = mpv_proc.poll() if ret is not None: if ret == 0: print("[player] mpv finished normally") stop_mpv(reason="finished") else: print(f"[player] mpv exited with code {ret}") publish_status("error", message=f"mpv exited code {ret}") stop_mpv(reason="mpv_error") # If we previously reported "busy", re-assert "playing" after delay if ( busy_until is not None and now >= busy_until and mpv_proc is not None and mpv_proc.poll() is None ): print("[player] clearing busy → playing") publish_status("playing") busy_until = None time.sleep(0.2) finally: client.loop_stop() stop_mpv(reason="shutdown") if __name__ == "__main__": main()