Initial Commit

This commit is contained in:
media
2026-06-14 14:27:26 -05:00
commit 5b2c5a3e14
3 changed files with 800 additions and 0 deletions
+718
View File
@@ -0,0 +1,718 @@
#!/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()