test: characterize contracts and make startup explicit

This commit is contained in:
Media
2026-08-06 17:24:20 -05:00
parent fc27feaea1
commit 9091f27283
9 changed files with 695 additions and 56 deletions
+73 -47
View File
@@ -10,27 +10,28 @@ from typing import Dict, List, Optional
import yaml
import paho.mqtt.client as mqtt
from controller.config import load_config
# --- Config / paths ---
MEDIA_BASE = Path(os.environ.get("YTPLAYER_MEDIA_BASE", "/mnt/Media/YTPlayer"))
DATA_BASE = Path(os.environ.get("YTPLAYER_DATA_BASE", "/controller-data"))
CONFIG = load_config()
MEDIA_BASE = CONFIG.storage.media_base
DATA_BASE = CONFIG.storage.data_base
STATE_PATH = DATA_BASE / "playlists.yaml"
MAX_SLOTS = int(os.environ.get("YTPLAYER_MAX_SLOTS", "10"))
RETENTION_SECONDS = int(os.environ.get("YTPLAYER_RETENTION_SECONDS", str(48 * 3600)))
MAX_SLOTS = len(CONFIG.slot_ids)
RETENTION_SECONDS = CONFIG.storage.retention_seconds
MQTT_HOST = os.environ.get("MQTT_HOST", "10.40.0.2")
MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
MQTT_CLIENT_ID = os.environ.get("MQTT_CLIENT_ID", "yt-controller")
MQTT_HOST = CONFIG.mqtt.host
MQTT_PORT = CONFIG.mqtt.port
MQTT_CLIENT_ID = CONFIG.mqtt.client_id
TOPIC_INGEST_PROGRESS = "youtube/ingest/progress"
TOPIC_PLAYBACK_BASE = "youtube/media/playback" # e.g. youtube/media/playback/1/title
TOPIC_PLAYER_STATUS = "youtube/player/status" # JSON from player_service
TOPIC_PLAYER_CMD = "youtube/player/cmd" # commands to player_service
TOPIC_CONTROLLER_CMD = "youtube/controller/cmd" # commands from HA / StreamDeck
MEDIA_BASE.mkdir(parents=True, exist_ok=True)
DATA_BASE.mkdir(parents=True, exist_ok=True)
TOPIC_INGEST_PROGRESS = CONFIG.topics.ingest_progress
TOPIC_PLAYBACK_BASE = CONFIG.topics.playback_base
TOPIC_PLAYER_STATUS = CONFIG.topics.player_status
TOPIC_PLAYER_CMD = CONFIG.topics.player_command
TOPIC_CONTROLLER_CMD = CONFIG.topics.controller_command
# --- MQTT client ---
@@ -43,6 +44,9 @@ def _on_connect(client, userdata, flags, rc, properties=None):
client.subscribe(TOPIC_PLAYER_STATUS, qos=1)
client.subscribe(TOPIC_CONTROLLER_CMD, qos=1)
print(f"[controller] subscribed to {TOPIC_PLAYER_STATUS} and {TOPIC_CONTROLLER_CMD}")
if CONFIG.homeassistant.enabled:
ha_discovery_publish()
reannounce_slots()
else:
print(f"[mqtt] connect failed rc={rc}")
@@ -111,26 +115,9 @@ def _handle_control_cmd(payload: str):
def _on_disconnect(client, userdata, rc, properties=None):
print(f"[mqtt] disconnected rc={rc}")
mqtt_client = mqtt.Client(client_id=MQTT_CLIENT_ID, clean_session=True)
# If your broker ever needs auth, you'd uncomment:
# mqtt_client.username_pw_set(os.environ.get("MQTT_USERNAME"), os.environ.get("MQTT_PASSWORD"))
mqtt_client.enable_logger() # send Paho logs to Python logging
mqtt_client.on_connect = _on_connect
mqtt_client.on_disconnect = _on_disconnect
mqtt_client.on_message = _on_message
print(f"[controller] attempting MQTT connect to {MQTT_HOST}:{MQTT_PORT} …")
mqtt_client.connect(MQTT_HOST, MQTT_PORT, keepalive=30)
mqtt_client.loop_start()
print("[controller] MQTT loop started")
mqtt_client.on_message = _on_message
mqtt_client.subscribe(TOPIC_PLAYER_STATUS, qos=1)
mqtt_client.subscribe(TOPIC_CONTROLLER_CMD, qos=1)
print(f"[controller] subscribed to {TOPIC_PLAYER_STATUS} and {TOPIC_CONTROLLER_CMD}")
mqtt_client = None
mqtt_started = False
mqtt_lifecycle_lock = threading.Lock()
def mqtt_publish(topic: str, payload, **kwargs):
@@ -141,7 +128,10 @@ def mqtt_publish(topic: str, payload, **kwargs):
if not isinstance(payload, str):
payload = json.dumps(payload)
print(f"[mqtt] publish {topic} -> {payload}")
mqtt_client.publish(topic, payload, **kwargs)
if mqtt_client is None:
print(f"[mqtt] publish skipped before Controller startup: {topic}")
return None
return mqtt_client.publish(topic, payload, **kwargs)
# --- State model ---
@@ -282,8 +272,6 @@ class ControllerState:
state = ControllerState()
state.load()
state.prune_expired()
# --- Helpers ---
@@ -698,7 +686,7 @@ def handle_play_request(cmd: Dict) -> None:
# --- HA Discovery ---
HA_DISCOVERY_PREFIX = os.environ.get("HA_DISCOVERY_PREFIX", "homeassistant")
HA_DISCOVERY_PREFIX = CONFIG.homeassistant.discovery_prefix
HA_DEVICE = {
"identifiers": ["ytplayer-controller"],
"name": "YouTube Controller",
@@ -826,13 +814,51 @@ def enqueue_download(url: str, submitter: Optional[str], note: Optional[str]) ->
}
# --- Post-define initialization that needs functions above ---
# --- Explicit lifecycle (imports are intentionally side-effect free) ---
print("[controller] running HA discovery publish…")
ha_discovery_publish()
print("[controller] ha_discovery_publish() done")
def start_controller() -> None:
"""Initialize storage and start non-blocking MQTT retry behavior."""
global mqtt_client, mqtt_started
with mqtt_lifecycle_lock:
if mqtt_started:
return
# Re-announce any existing slots so HA / Deck see them after a restart
for s, slot in state.slots.items():
publish_slot_topics(slot)
print("[controller] re-published slot states")
MEDIA_BASE.mkdir(parents=True, exist_ok=True)
DATA_BASE.mkdir(parents=True, exist_ok=True)
state.load()
mqtt_client = mqtt.Client(client_id=MQTT_CLIENT_ID, clean_session=True)
if CONFIG.mqtt.username is not None:
mqtt_client.username_pw_set(CONFIG.mqtt.username, CONFIG.mqtt.password)
mqtt_client.enable_logger()
mqtt_client.on_connect = _on_connect
mqtt_client.on_disconnect = _on_disconnect
mqtt_client.on_message = _on_message
mqtt_client.reconnect_delay_set(
min_delay=CONFIG.mqtt.reconnect_min_seconds,
max_delay=CONFIG.mqtt.reconnect_max_seconds,
)
print(f"[controller] attempting asynchronous MQTT connect to {MQTT_HOST}:{MQTT_PORT} …")
mqtt_client.connect_async(
MQTT_HOST,
MQTT_PORT,
keepalive=CONFIG.mqtt.keepalive_seconds,
)
mqtt_client.loop_start()
mqtt_started = True
print("[controller] MQTT loop started")
def stop_controller() -> None:
"""Stop the MQTT client if explicit startup occurred."""
global mqtt_client, mqtt_started
with mqtt_lifecycle_lock:
if not mqtt_started or mqtt_client is None:
return
try:
mqtt_client.disconnect()
finally:
mqtt_client.loop_stop()
mqtt_client = None
mqtt_started = False