Files
Video-Player-Node/player_service.py
T
2026-08-06 16:46:04 -05:00

1083 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
import json
import math
import socket
import os
import queue
import signal
import subprocess
import sys
import time
import traceback
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Dict, Any
import threading
import paho.mqtt.client as mqtt
import yaml
# ----------------------------
# 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_CONFIG = config.get("homeassistant", {})
HA_DISCOVERY_ENABLED = HA_CONFIG.get("enabled", True)
HA_DISCOVERY_PREFIX = HA_CONFIG.get("discovery_prefix", "homeassistant")
HA_DEVICE_ID = HA_CONFIG.get("device_id", "ytplayer")
HA_DEVICE_NAME = HA_CONFIG.get("device_name", "YT Player")
HA_DISCOVERY_IMPORT_ERROR: Optional[Exception] = None
if HA_DISCOVERY_ENABLED:
try:
from homeassistant_discovery import publish_discovery
except Exception as exc:
# Discovery is optional. A broken/missing helper must not prevent the
# core player and its established MQTT interface from starting.
publish_discovery = None
HA_DISCOVERY_IMPORT_ERROR = exc
else:
publish_discovery = None
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 = max(0.1, float(config["player"].get("telemetry_interval", 0.5)))
COMMANDS_PER_TICK = max(1, int(config["player"].get("commands_per_tick", 32)))
COMMAND_QUEUE_SIZE = max(1, int(config["player"].get("command_queue_size", 512)))
MPV_AUDIO_DEVICE = config["player"].get("audio_device", "pipewire/ytplayer_bus")
MPV_AUDIO_CLIENT_NAME = config["player"].get("audio_client_name", "YTPlayer")
MPV_AUDIO_CHANNELS = config["player"].get("audio_channels", "stereo")
# ----------------------------
# Runtime state
# ----------------------------
@dataclass
class PlaybackContext:
generation: int
process: subprocess.Popen
path: str
slot: Optional[int]
stop_event: threading.Event
listener_thread: Optional[threading.Thread] = None
@dataclass(frozen=True)
class QueuedCommand:
data: Dict[str, Any]
message_id: Optional[int] = None
qos: int = 0
connection_generation: Optional[int] = None
current_playback: Optional[PlaybackContext] = None
playback_generation = 0
busy_until: Optional[float] = None
state_lock = threading.RLock()
shutdown_event = threading.Event()
mqtt_generation_lock = threading.Lock()
mqtt_connection_generation = 0
command_queue: "queue.Queue[QueuedCommand]" = queue.Queue(maxsize=COMMAND_QUEUE_SIZE)
client = mqtt.Client(
mqtt.CallbackAPIVersion.VERSION2,
client_id=f"{CLIENT_ID_PREFIX}-player",
clean_session=True,
)
def log(message: str, *, error: bool = False) -> None:
"""Write each record immediately so journald timestamps are accurate."""
print(message, file=sys.stderr if error else sys.stdout, flush=True)
def log_exception(message: str) -> None:
log(message, error=True)
traceback.print_exc(file=sys.stderr)
# ----------------------------
# MQTT helpers
# ----------------------------
_USE_CURRENT_PLAYBACK = object()
def get_current_playback() -> Optional[PlaybackContext]:
with state_lock:
return current_playback
def playback_is_current(playback: PlaybackContext) -> bool:
with state_lock:
return current_playback is playback and not playback.stop_event.is_set()
def publish_status(
state: str,
*,
playback: Any = _USE_CURRENT_PLAYBACK,
include_path: bool = True,
**extra: Any,
) -> None:
"""
Publish:
- JSON status (for HA / controller) to TOPIC_STATUS
- Flat string (for Companion color mapping) to TOPIC_STATE
"""
if playback is _USE_CURRENT_PLAYBACK:
playback = get_current_playback()
payload: Dict[str, Any] = {"state": state}
if playback is not None:
if include_path and playback.path:
payload["path"] = playback.path
if playback.slot is not None:
payload["slot"] = playback.slot
payload.update(extra)
try:
# These topic names, QoS levels, retain flags, and payload shapes are
# part of the existing controller contract. Do not change them here.
client.publish(TOPIC_STATUS, json.dumps(payload), qos=1, retain=False)
client.publish(TOPIC_STATE, state, qos=0, retain=True)
except Exception:
log_exception(f"[player] failed to publish status {state!r}")
# ----------------------------
# MPV control
# ----------------------------
def _remove_mpv_socket() -> None:
try:
os.unlink(MPV_SOCKET)
except FileNotFoundError:
pass
except OSError as exc:
log(f"[player] could not remove stale MPV socket {MPV_SOCKET}: {exc}", error=True)
def _join_playback_workers(playback: PlaybackContext) -> None:
for worker in (playback.listener_thread,):
if worker is None or worker is threading.current_thread():
continue
try:
worker.join(timeout=1.5)
except RuntimeError:
# Thread construction succeeded but start() did not.
continue
if worker.is_alive():
log(
f"[player] worker {worker.name!r} did not stop promptly "
f"for generation {playback.generation}",
error=True,
)
def stop_mpv(
reason: str = "stop",
*,
expected: Optional[PlaybackContext] = None,
final_state: str = "idle",
message: Optional[str] = None,
publish_updates: bool = True,
) -> bool:
"""
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 current_playback, busy_until
with state_lock:
playback = current_playback
if expected is not None and playback is not expected:
return False
busy_until = None
if playback is not None:
playback.stop_event.set()
stop_error: Optional[str] = None
if playback is not None and playback.process.poll() is None:
try:
playback.process.terminate()
try:
playback.process.wait(timeout=5)
except subprocess.TimeoutExpired:
playback.process.kill()
playback.process.wait(timeout=2)
except Exception as exc:
stop_error = f"Failed to stop mpv: {exc}"
log(f"[player] {stop_error}", error=True)
# terminate() itself can fail. Make a separate best-effort kill so
# the daemon never abandons a live child and starts a duplicate.
if playback.process.poll() is None:
try:
playback.process.kill()
playback.process.wait(timeout=2)
except Exception as kill_exc:
log(
f"[player] Failed to kill mpv after stop error: {kill_exc}",
error=True,
)
if playback is not None and playback.process.poll() is None:
# Keep ownership of a child that could not be stopped. A later stop or
# systemd's cgroup cleanup can retry; handle_play will remain busy and
# cannot create a second MPV instance.
if publish_updates:
publish_status(
"error",
playback=playback,
message=stop_error or "Failed to stop mpv; process is still running",
)
return False
with state_lock:
if current_playback is playback:
current_playback = None
if playback is not None:
_join_playback_workers(playback)
_remove_mpv_socket()
if not publish_updates:
return True
if stop_error is not None:
# Preserve the historical error-then-idle state sequence for existing
# MQTT consumers while still completing cleanup.
publish_status("error", playback=playback, message=stop_error)
# Keep the previous slot in the clear/terminal messages, matching the
# existing controller contract, but do not carry over the old path.
publish_cleared_telemetry(
playback=None if stop_error is not None else playback,
)
extra: Dict[str, Any] = {"reason": reason}
if message is not None:
extra["message"] = message
publish_status(
final_state,
playback=playback,
include_path=final_state == "error",
**extra,
)
return True
def handle_play(cmd: Dict[str, Any]) -> None:
"""
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 current_playback, playback_generation, busy_until
log(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")
if path is not None and not isinstance(path, str):
log(f"[player] bad path value: {path!r}", error=True)
publish_status("error", message="play command path must be a string")
return
if playlist is not None and not isinstance(playlist, str):
log(f"[player] bad playlist value: {playlist!r}", error=True)
publish_status("error", message="play command playlist must be a string")
return
slot_num: Optional[int] = None
if slot is not None:
try:
if isinstance(slot, bool):
raise ValueError("boolean slots are not valid")
slot_num = int(slot)
except (TypeError, ValueError, OverflowError):
if path or playlist:
log(f"[player] ignoring bad optional slot value: {slot!r}", error=True)
slot_num = None
else:
log(f"[player] bad slot value: {slot!r}", error=True)
publish_status("error", message=f"bad slot value: {slot!r}")
return
# Decide what we're actually going to play.
final_path: Optional[str] = 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_num is not None:
final_path = f"{MEDIA_BASE}/slot{slot_num}/playlist.m3u"
if not final_path:
log("[player] no valid 'path', 'playlist' or 'slot' supplied", error=True)
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):
log(f"[player] path not found: {final_path}", error=True)
publish_status("error", message=f"path not found: {final_path}")
return
# enforce “must be stopped before new playlist”
existing = get_current_playback()
if existing is not None and existing.process.poll() is None:
log("[player] already playing, sending busy")
publish_status("busy", message="mpv already playing; stop first")
busy_until = time.monotonic() + 5
return
if existing is not None:
stop_mpv(
reason="stale_process",
expected=existing,
publish_updates=False,
)
args = [
MPV_BIN,
f"--input-ipc-server={MPV_SOCKET}",
f"--audio-device={MPV_AUDIO_DEVICE}",
f"--audio-client-name={MPV_AUDIO_CLIENT_NAME}",
f"--audio-channels={MPV_AUDIO_CHANNELS}",
# Never fall back to a physical default sink. Audio Router is the sole
# owner of the HDMI/analog choice.
"--audio-fallback-to-null=no",
# Keep warnings/errors but suppress the continuously rewritten AV
# progress line that journald stores as very large blob records.
"--quiet",
]
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
_remove_mpv_socket()
process: Optional[subprocess.Popen] = None
playback: Optional[PlaybackContext] = None
try:
log(f"[player] starting mpv: {args!r}")
process = subprocess.Popen(
args,
env=env,
stdout=None,
stderr=None,
)
with state_lock:
playback_generation += 1
playback = PlaybackContext(
generation=playback_generation,
process=process,
path=final_path,
slot=slot_num,
stop_event=threading.Event(),
)
# Establish ownership before constructing any worker. If worker
# setup raises, stop_mpv can still reap this exact child.
current_playback = playback
playback.listener_thread = threading.Thread(
target=mpv_observe_properties,
args=(playback,),
name=f"mpv-telemetry-{playback.generation}",
daemon=True,
)
except Exception as exc:
log(f"[player] failed to initialize mpv playback: {exc}", error=True)
if playback is not None:
stop_mpv(
reason="startup_error",
expected=playback,
final_state="error",
message=f"failed to start mpv: {exc}",
)
elif process is not None:
# Context construction itself failed after Popen. Reap the local
# child directly because it was never safe to publish as current.
try:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=2)
except Exception:
try:
if process.poll() is None:
process.kill()
process.wait(timeout=2)
except Exception:
log_exception("[player] failed to reap unowned mpv process")
publish_status("error", message=f"failed to start mpv: {exc}")
else:
publish_status("error", message=f"failed to start mpv: {exc}")
return
publish_status("playing", playback=playback, label=label)
try:
playback.listener_thread.start()
except Exception as exc:
log_exception("[player] failed to start playback worker")
stop_mpv(
reason="worker_start_error",
expected=playback,
final_state="error",
message=f"failed to start playback worker: {exc}",
)
def mpv_ipc(command: Any, playback: Optional[PlaybackContext] = None) -> bool:
"""Send a JSON IPC command to MPV via MPV_SOCKET."""
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
if shutdown_event.is_set():
return False
if playback is not None and not playback_is_current(playback):
return False
try:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock:
sock.settimeout(0.5)
sock.connect(MPV_SOCKET)
sock.sendall((json.dumps({"command": command}) + "\n").encode("utf-8"))
return True
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
shutdown_event.wait(0.05)
except OSError as exc:
log(f"[player] mpv_ipc error: {exc}", error=True)
return False
log(f"[player] mpv_ipc timed out waiting for {MPV_SOCKET}", error=True)
return False
def fmt_mmss(value: Optional[float]) -> Optional[str]:
if (
not isinstance(value, (int, float))
or not math.isfinite(value)
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(
playback: PlaybackContext,
*,
pos: Optional[float],
dur: Optional[float],
paused: Optional[bool],
title: Optional[str],
filename: Optional[str],
playlist_pos: Optional[int],
playlist_count: Optional[int],
) -> None:
if not playback_is_current(playback):
return
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": playback.slot,
"path": playback.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(),
}
try:
client.publish(TELEMETRY_TOPIC, json.dumps(payload), qos=0, retain=False)
except Exception:
log_exception("[player] failed to publish telemetry")
def publish_cleared_telemetry(playback: Optional[PlaybackContext] = None) -> None:
payload: Dict[str, Any] = {
"state": "telemetry",
"slot": playback.slot if playback is not None else None,
"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(),
}
try:
client.publish(TELEMETRY_TOPIC, json.dumps(payload), qos=0, retain=False)
except Exception:
log_exception("[player] failed to publish cleared telemetry")
def mpv_observe_properties(playback: PlaybackContext) -> None:
"""
Keep a persistent IPC connection to mpv and observe properties.
Publishes telemetry to MQTT at TELEMETRY_INTERVAL cadence.
"""
# 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
recv_buffer = b""
sock: Optional[socket.socket] = None
def publish_cached() -> None:
nonlocal last_pub
if not playback_is_current(playback):
return
publish_telemetry(
playback,
pos=pos,
dur=dur,
paused=paused,
title=title,
filename=filename,
playlist_pos=playlist_pos,
playlist_count=playlist_count,
)
last_pub = time.monotonic()
try:
deadline = time.monotonic() + 3.0
while playback_is_current(playback):
candidate = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
try:
candidate.connect(MPV_SOCKET)
sock = candidate
break
except (FileNotFoundError, ConnectionRefusedError):
candidate.close()
if time.monotonic() > deadline:
raise
playback.stop_event.wait(0.05)
else:
return
if sock is None:
return
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
log(f"[player] telemetry connected to mpv socket {MPV_SOCKET}")
publish_cached()
while playback_is_current(playback):
try:
chunk = sock.recv(65536)
if not chunk:
break
recv_buffer += chunk
lines = recv_buffer.split(b"\n")
recv_buffer = lines.pop()
for raw_line in lines:
if not raw_line.strip():
continue
try:
evt = json.loads(raw_line.decode("utf-8", errors="replace"))
except (UnicodeDecodeError, json.JSONDecodeError):
continue
if not isinstance(evt, dict):
continue
if evt.get("event") != "property-change":
continue
name = evt.get("name")
val = evt.get("data")
finite_number = (
isinstance(val, (int, float))
and not isinstance(val, bool)
and math.isfinite(val)
)
if name == "time-pos":
pos = float(val) if finite_number else None
elif name == "duration":
dur = float(val) if finite_number 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 finite_number else None
elif name == "playlist-count":
playlist_count = int(val) if finite_number else None
now = time.monotonic()
if now - last_pub >= TELEMETRY_INTERVAL:
publish_cached()
except socket.timeout:
# still periodically publish even if no new events
now = time.monotonic()
if now - last_pub >= TELEMETRY_INTERVAL:
publish_cached()
continue
except Exception as exc:
if playback_is_current(playback):
log(f"[player] mpv_observe_properties error: {exc}", error=True)
finally:
if sock is not None:
try:
sock.close()
except OSError:
pass
# ----------------------------
# Command dispatch
# ----------------------------
def decode_command(payload_str: str) -> Optional[Dict[str, Any]]:
"""Decode and validate the outer command envelope without raising."""
try:
data = json.loads(payload_str or "{}")
except (TypeError, ValueError, RecursionError):
log(f"[player] bad JSON in cmd: {payload_str!r}", error=True)
return None
if not isinstance(data, dict):
log("[player] command payload must be a JSON object", error=True)
return None
action = data.get("action")
if not isinstance(action, str) or not action.strip():
log("[player] cmd missing string 'action'", error=True)
return None
return data
def enqueue_command(
data: Dict[str, Any],
*,
message_id: Optional[int] = None,
qos: int = 0,
connection_generation: Optional[int] = None,
) -> bool:
"""Hand work to the main thread with bounded, lossless backpressure."""
queued = QueuedCommand(
data=data,
message_id=message_id,
qos=qos,
connection_generation=connection_generation,
)
while not shutdown_event.is_set():
try:
command_queue.put(queued, timeout=0.2)
return True
except queue.Full:
continue
return False
def acknowledge_command(
message_id: Optional[int],
qos: int,
connection_generation: Optional[int] = None,
) -> None:
if message_id is None or qos <= 0:
return
if connection_generation is not None:
with mqtt_generation_lock:
if connection_generation != mqtt_connection_generation:
log(
f"[player] skipping stale MQTT ack mid={message_id} from "
f"connection {connection_generation}",
error=True,
)
return
try:
ack_rc = client.ack(message_id, qos)
if ack_rc != mqtt.MQTT_ERR_SUCCESS:
log(
f"[player] MQTT ack failed mid={message_id} rc={ack_rc}",
error=True,
)
except Exception:
log_exception(f"[player] MQTT ack failed mid={message_id}")
def handle_cmd(command: Any) -> None:
"""Route one validated command on the main player thread."""
if isinstance(command, str):
data = decode_command(command)
if data is None:
return
elif isinstance(command, dict):
data = command
else:
log("[player] internal command was not an object", error=True)
return
action = data.get("action")
if not isinstance(action, str) or not action:
log("[player] cmd missing string 'action'", error=True)
return
log(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":
log("[player] STOP command")
stop_mpv("stop")
return
playback = get_current_playback()
if action == "pause":
if playback is not None and playback.process.poll() is None:
mpv_ipc(["set_property", "pause", True], playback)
publish_status("paused", playback=playback)
else:
publish_status("error", message="nothing playing to pause")
return
if action == "resume":
if playback is not None and playback.process.poll() is None:
mpv_ipc(["set_property", "pause", False], playback)
publish_status("playing", playback=playback)
else:
publish_status("error", message="nothing playing to resume")
return
if action == "toggle":
if playback is not None and playback.process.poll() is None:
mpv_ipc(["cycle", "pause"], playback)
publish_status("toggled", playback=playback)
else:
publish_status("error", message="nothing playing to toggle")
return
log(f"[player] unknown action: {action!r}", error=True)
# ----------------------------
# MQTT callbacks
# ----------------------------
def on_connect(mqtt_client, userdata, flags, rc, properties=None) -> None:
global mqtt_connection_generation
try:
if rc != 0:
log(f"[player] MQTT connect failed with code {rc}", error=True)
return
with mqtt_generation_lock:
mqtt_connection_generation += 1
log(f"[player] MQTT connected rc={rc}, subscribing {TOPIC_CMD}")
mqtt_client.subscribe(TOPIC_CMD, qos=1)
publish_status("idle", message="player online")
if HA_DISCOVERY_ENABLED:
try:
if publish_discovery is None:
raise RuntimeError("Home Assistant discovery module is unavailable")
publish_discovery(
mqtt_client,
discovery_prefix=HA_DISCOVERY_PREFIX,
device_id=HA_DEVICE_ID,
device_name=HA_DEVICE_NAME,
telemetry_topic=TELEMETRY_TOPIC,
status_topic=TOPIC_STATUS,
)
except Exception:
# Discovery is optional and must never disable core MQTT
# command handling.
log_exception("[player] Home Assistant discovery publish failed")
except Exception:
log_exception("[player] unexpected error in MQTT on_connect callback")
def on_connect_fail(mqtt_client, userdata) -> None:
log(
f"[player] MQTT connection to {MQTT_HOST}:{MQTT_PORT} failed; will retry",
error=True,
)
def on_disconnect(
mqtt_client,
userdata,
disconnect_flags,
reason_code,
properties=None,
) -> None:
if shutdown_event.is_set():
log(f"[player] MQTT disconnected rc={reason_code}")
else:
log(f"[player] MQTT disconnected rc={reason_code}; will retry", error=True)
def on_message(mqtt_client, userdata, msg) -> None:
try:
if msg.topic != TOPIC_CMD:
return
payload = msg.payload.decode("utf-8", errors="replace")
data = decode_command(payload)
message_id = getattr(msg, "mid", None)
qos = int(getattr(msg, "qos", 0))
with mqtt_generation_lock:
connection_generation = mqtt_connection_generation
if data is None:
# Validation completed synchronously, matching the old callback's
# acknowledgement point for rejected/poison messages.
acknowledge_command(message_id, qos, connection_generation)
return
enqueue_command(
data,
message_id=message_id,
qos=qos,
connection_generation=connection_generation,
)
except Exception:
# Paho otherwise terminates its network thread and leaves the process
# looking healthy to systemd while it is permanently deaf to commands.
log_exception("[player] unexpected error in MQTT on_message callback")
# ----------------------------
# Signal handling
# ----------------------------
shutdown_reason = "shutdown"
def sigterm_handler(signum, frame) -> None:
global shutdown_reason
shutdown_reason = "sigterm" if signum == signal.SIGTERM else "sigint"
log(f"[player] {signal.Signals(signum).name} received, shutting down")
shutdown_event.set()
# ----------------------------
# Main loop
# ----------------------------
def poll_current_playback() -> None:
playback = get_current_playback()
if playback is None:
return
ret = playback.process.poll()
if ret is None:
return
if ret == 0:
log("[player] mpv finished normally")
stop_mpv(reason="finished", expected=playback)
else:
log(f"[player] mpv exited with code {ret}", error=True)
publish_status("error", playback=playback, message=f"mpv exited code {ret}")
stop_mpv(reason="mpv_error", expected=playback)
def process_pending_commands() -> None:
for _ in range(COMMANDS_PER_TICK):
if shutdown_event.is_set():
return
try:
queued = command_queue.get_nowait()
except queue.Empty:
return
if isinstance(queued, QueuedCommand):
command = queued.data
message_id = queued.message_id
qos = queued.qos
connection_generation = queued.connection_generation
else:
# Backward-compatible for in-process callers/tests that populated
# the queue before QueuedCommand was introduced.
command = queued
message_id = None
qos = 0
connection_generation = None
try:
handle_cmd(command)
except Exception:
# A bad command must not terminate the daemon's main loop.
log_exception(f"[player] unexpected command failure: {command!r}")
finally:
# Match the old QoS 1 durability boundary: acknowledge only after
# main-thread execution, not merely after callback enqueueing.
acknowledge_command(message_id, qos, connection_generation)
def clear_expired_busy_status() -> None:
global busy_until
playback = get_current_playback()
if (
busy_until is not None
and time.monotonic() >= busy_until
and playback is not None
and playback.process.poll() is None
):
log("[player] clearing busy -> playing")
publish_status("playing", playback=playback)
busy_until = None
def configure_mqtt_client() -> None:
if MQTT_USER:
client.username_pw_set(MQTT_USER, MQTT_PASS or None)
client.on_connect = on_connect
client.on_connect_fail = on_connect_fail
client.on_disconnect = on_disconnect
client.on_message = on_message
client.suppress_exceptions = True
client.manual_ack_set(True)
client.reconnect_delay_set(min_delay=1, max_delay=30)
client.will_set(
TOPIC_STATUS,
json.dumps({"state": "offline"}),
qos=1,
retain=False,
)
def main() -> None:
global shutdown_reason
signal.signal(signal.SIGTERM, sigterm_handler)
signal.signal(signal.SIGINT, sigterm_handler)
shutdown_reason = "shutdown"
shutdown_event.clear()
configure_mqtt_client()
log(f"[player] connecting MQTT to {MQTT_HOST}:{MQTT_PORT}")
client.connect_async(MQTT_HOST, MQTT_PORT, keepalive=30)
loop_rc = client.loop_start()
if loop_rc != mqtt.MQTT_ERR_SUCCESS:
raise RuntimeError(f"failed to start MQTT network loop: {loop_rc}")
try:
while not shutdown_event.is_set():
poll_current_playback()
process_pending_commands()
clear_expired_busy_status()
shutdown_event.wait(0.05)
finally:
if shutdown_reason in ("sigterm", "sigint"):
# The historical signal handler published its terminal player
# state while MQTT was still running.
stop_mpv(reason=shutdown_reason)
client.loop_stop()
else:
client.loop_stop()
stop_mpv(reason=shutdown_reason)
# Do not send MQTT DISCONNECT: the broker must emit the configured
# offline LWT when the service process exits, as it did previously.
if __name__ == "__main__":
main()