Files
Video-Player-Controller/controller_core.py
T

865 lines
26 KiB
Python

import os
import time
import json
import threading
import subprocess
from pathlib import Path
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
import yaml
import paho.mqtt.client as mqtt
from controller.config import load_config
# --- Config / paths ---
CONFIG = load_config()
MEDIA_BASE = CONFIG.storage.media_base
DATA_BASE = CONFIG.storage.data_base
STATE_PATH = DATA_BASE / "playlists.yaml"
MAX_SLOTS = len(CONFIG.slot_ids)
RETENTION_SECONDS = CONFIG.storage.retention_seconds
MQTT_HOST = CONFIG.mqtt.host
MQTT_PORT = CONFIG.mqtt.port
MQTT_CLIENT_ID = CONFIG.mqtt.client_id
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 ---
import logging, sys
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
def _on_connect(client, userdata, flags, rc, properties=None):
print(f"[mqtt] connected rc={rc}")
if rc == 0:
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}")
def _on_message(client, userdata, msg):
if msg.topic == TOPIC_PLAYER_STATUS:
try:
data = json.loads(msg.payload.decode("utf-8") or "{}")
except Exception:
return
state_str = data.get("state")
slot_val = data.get("slot")
if not state_str or slot_val is None:
return
try:
slot_num = int(slot_val)
except (TypeError, ValueError):
return
print(f"[controller] player status: slot={slot_num} state={state_str}")
_update_slot_state_from_player(slot_num, state_str)
elif msg.topic == TOPIC_CONTROLLER_CMD:
_handle_control_cmd(msg.payload.decode("utf-8", errors="ignore"))
def _handle_control_cmd(payload: str):
"""
Handle messages on TOPIC_CONTROLLER_CMD, e.g.:
{"action": "delete_slot", "slot": 2, "url": "..."}
{"action": "play", "slot": 1}
{"action": "stop"}
"""
try:
data = json.loads(payload or "{}")
except json.JSONDecodeError:
print(f"[controller] bad JSON on control cmd: {payload!r}")
return
action = data.get("action")
if not action:
print("[controller] control cmd missing 'action'")
return
# delete slot
if action == "delete_slot":
slot = data.get("slot")
try:
slot_num = int(slot)
except (TypeError, ValueError):
print(f"[controller] delete_slot: bad slot {slot!r}")
return
expected_url = data.get("url") # optional safety
delete_slot(slot_num, expected_url=expected_url)
return
# play / stop → hand off to the orchestrator
if action in ("play", "stop"):
handle_play_request(data)
return
print(f"[controller] unknown control action: {action!r}")
def _on_disconnect(client, userdata, rc, properties=None):
print(f"[mqtt] disconnected rc={rc}")
mqtt_client = None
mqtt_started = False
mqtt_lifecycle_lock = threading.Lock()
def mqtt_publish(topic: str, payload, **kwargs):
"""
Small wrapper that JSON-encodes non-strings and prints what we publish.
Keeps logs useful and avoids repeating code.
"""
if not isinstance(payload, str):
payload = json.dumps(payload)
print(f"[mqtt] publish {topic} -> {payload}")
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 ---
@dataclass
class PlaylistSlot:
slot: int
url: str
yt_id: Optional[str]
title: str
path: str
m3u: str
created_at: float
expires_at: float
item_count: int
state: str # "downloading" | "ready" | "error" | "playing" | "busy"
def to_dict(self):
d = asdict(self)
# timestamps as ISO-ish strings
d["created_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.created_at))
d["expires_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(self.expires_at))
return d
@staticmethod
def from_dict(d: Dict):
def parse_ts(s: str) -> float:
try:
# naive ISO Z
return time.mktime(time.strptime(s, "%Y-%m-%dT%H:%M:%SZ"))
except Exception:
return float(d.get("created_at_ts", time.time()))
created_at = parse_ts(d.get("created_at", "1970-01-01T00:00:00Z"))
expires_at = parse_ts(d.get("expires_at", "1970-01-01T00:00:00Z"))
return PlaylistSlot(
slot=int(d["slot"]),
url=d["url"],
yt_id=d.get("yt_id"),
title=d.get("title", ""),
path=d["path"],
m3u=d["m3u"],
created_at=created_at,
expires_at=expires_at,
item_count=int(d.get("item_count", 0)),
state=d.get("state", "ready"),
)
class ControllerState:
def __init__(self):
self.slots: Dict[int, PlaylistSlot] = {}
def load(self):
if not STATE_PATH.exists():
return
data = yaml.safe_load(STATE_PATH.read_text()) or {}
slots = data.get("slots", {})
self.slots.clear()
for k, v in slots.items():
try:
slot = PlaylistSlot.from_dict(v)
self.slots[slot.slot] = slot
except Exception:
continue
def save(self):
data = {"slots": {str(s): slot.to_dict() for s, slot in self.slots.items()}}
STATE_PATH.write_text(yaml.safe_dump(data, sort_keys=True))
def prune_expired(self):
"""
Remove any slots whose expires_at is in the past, delete their folders,
compact remaining slots, save, and reannounce MQTT so ghosts disappear.
"""
now = time.time()
changed = False
to_delete: List[int] = []
for s, slot in list(self.slots.items()):
if now >= slot.expires_at:
to_delete.append(s)
for s in to_delete:
slot = self.slots.pop(s, None)
if not slot:
continue
try:
p = Path(slot.path)
if p.exists():
for child in p.iterdir():
try:
child.unlink()
except IsADirectoryError:
# ignore unexpected subdirs
pass
p.rmdir()
except Exception:
pass
changed = True
if changed:
# compact slot numbers (1..N), save, then resync MQTT topics
self.compact_slots()
self.save()
# this is the helper you already have below
reannounce_slots()
def compact_slots(self):
"""Shift slots down so there are no gaps: 1..N"""
if not self.slots:
return
new_slots: Dict[int, PlaylistSlot] = {}
for new_index, slot in enumerate(sorted(self.slots.values(), key=lambda sl: sl.slot), start=1):
if slot.slot != new_index:
old_dir = Path(slot.path)
new_dir = MEDIA_BASE / f"slot{new_index}"
# rename folder if it exists
if old_dir.exists() and old_dir != new_dir:
new_dir.parent.mkdir(parents=True, exist_ok=True)
old_dir.rename(new_dir)
slot.slot = new_index
slot.path = str(new_dir)
slot.m3u = str(new_dir / "playlist.m3u")
new_slots[new_index] = slot
self.slots = new_slots
def get_next_free_slot(self) -> Optional[int]:
self.prune_expired()
used = set(self.slots.keys())
for i in range(1, MAX_SLOTS + 1):
if i not in used:
return i
return None
state = ControllerState()
# --- Helpers ---
def delete_slot(slot_num: int, expected_url: Optional[str] = None):
"""
Delete a slot from state + filesystem, then compact and republish.
expected_url is optional: if provided and doesn't match, we ignore
the delete (safety against stale retained messages if someone ever
accidentally turns retain on).
"""
state.load()
slot = state.slots.get(slot_num)
if not slot:
print(f"[controller] delete_slot: slot {slot_num} not found, ignoring")
return
if expected_url and slot.url != expected_url:
print(f"[controller] delete_slot: url mismatch for slot {slot_num}, ignoring")
return
print(f"[controller] deleting slot {slot_num}: {slot.title!r}")
# Remove files on disk
slot_dir = Path(slot.path)
if slot_dir.exists():
for child in slot_dir.iterdir():
try:
child.unlink()
except IsADirectoryError:
# no subdirs expected, but don't crash
pass
try:
slot_dir.rmdir()
except OSError:
pass
# Remove from state
state.slots.pop(slot_num, None)
# Compact and save
state.compact_slots()
state.save()
# Re-publish MQTT for all remaining slots, so HA/Deck are in sync
reannounce_slots()
def reannounce_slots():
"""
Re-publish MQTT state/title/count for all existing slots,
and mark any unused slots as empty.
"""
state.load()
# Re-announce real slots
for s, slot in sorted(state.slots.items()):
publish_slot_topics(slot)
# Clear any slots above the current highest used index
highest = max(state.slots.keys(), default=0)
for s in range(highest + 1, MAX_SLOTS + 1):
base = f"{TOPIC_PLAYBACK_BASE}/{s}"
mqtt_publish(f"{base}/title", "", retain=True)
mqtt_publish(f"{base}/state", "empty", retain=True)
mqtt_publish(f"{base}/count", "0", retain=True)
# also clear item names
for i in range(1, 100):
mqtt_publish(f"{base}/items/{i}", "", retain=True)
def publish_slot_topics(slot: PlaylistSlot):
"""Publish the title/state/count for one slot to its MQTT topics."""
base = f"{TOPIC_PLAYBACK_BASE}/{slot.slot}"
mqtt_publish(f"{base}/title", slot.title, retain=True)
mqtt_publish(f"{base}/state", slot.state, retain=True)
mqtt_publish(f"{base}/count", slot.item_count, retain=True)
def set_slot_busy_temporarily(slot: PlaylistSlot, seconds: int = 5):
"""
Briefly mark a slot as 'busy', then restore after a delay.
"""
base = f"{TOPIC_PLAYBACK_BASE}/{slot.slot}"
mqtt_publish(f"{base}/state", "busy", retain=True)
def _clear_busy_later(slot_number: int, delay: int):
time.sleep(delay)
state.load()
sl = state.slots.get(slot_number)
if sl is None:
base2 = f"{TOPIC_PLAYBACK_BASE}/{slot_number}"
mqtt_publish(f"{base2}/title", "", retain=True)
mqtt_publish(f"{base2}/state", "empty", retain=True)
mqtt_publish(f"{base2}/count", 0, retain=True)
return
publish_slot_topics(sl)
t = threading.Thread(
target=_clear_busy_later,
args=(slot.slot, seconds),
daemon=True,
)
t.start()
def _parse_track_title_from_filename(path: Path) -> str:
stem = path.stem # "000 - Song Name [id]"
# strip leading "000 - "
if " - " in stem:
stem = stem.split(" - ", 1)[1]
# strip trailing " [id]"
if " [" in stem:
stem = stem.rsplit(" [", 1)[0]
return stem
def _update_slot_state_from_player(slot_num: int, new_state: str):
"""
Mirror player state ("playing", "busy", "idle", "error", etc.)
into our per-slot state + MQTT topics.
"""
slot_obj = state.slots.get(slot_num)
if not slot_obj:
return
# Map "idle" / "finished" from player back to "ready" for the slot UI
if new_state in ("idle", "finished"):
slot_obj.state = "ready"
mqtt_state = "ready"
else:
slot_obj.state = new_state
mqtt_state = new_state
state.save()
publish_slot_topics(slot_obj)
def _get_title_for_url(url: str) -> str:
"""
Try to detect a human-friendly title for either:
- a playlist (use playlist_title)
- a single video (fall back to video title)
If we can't figure it out, just return the URL.
"""
try:
# One line: playlist_title|||video_title
out = subprocess.check_output(
[
"yt-dlp",
"--flat-playlist",
"--print", "%(playlist_title)s|||%(title)s",
"--playlist-end", "1",
url,
],
stderr=subprocess.DEVNULL,
text=True,
).strip()
if not out:
return url
line = out.splitlines()[0]
playlist_title, _, video_title = line.partition("|||")
playlist_title = (playlist_title or "").strip()
video_title = (video_title or "").strip()
# If playlist_title looks real (not empty/NA), use it
if playlist_title and playlist_title.upper() != "NA":
return playlist_title
# Otherwise fall back to video title
if video_title:
return video_title
return url
except Exception:
return url
def _download_playlist_to_slot(url: str, slot_num: int):
"""
Runs yt-dlp into /mnt/Media/YTPlayer/slotN, builds playlist.m3u,
updates YAML, and publishes MQTT topics.
"""
slot_dir = MEDIA_BASE / f"slot{slot_num}"
slot_dir.mkdir(parents=True, exist_ok=True)
title = _get_title_for_url(url)
now = time.time()
expires_at = now + RETENTION_SECONDS
# mark slot as downloading
slot = PlaylistSlot(
slot=slot_num,
url=url,
yt_id=None,
title=title,
path=str(slot_dir),
m3u=str(slot_dir / "playlist.m3u"),
created_at=now,
expires_at=expires_at,
item_count=0,
state="downloading",
)
state.slots[slot_num] = slot
state.save()
# Tell HA/Deck: this slot is downloading now
publish_slot_topics(slot)
mqtt_publish(
TOPIC_INGEST_PROGRESS,
{"slot": slot_num, "state": "downloading", "title": title},
)
# run yt-dlp
try:
subprocess.run(
[
"yt-dlp",
"--cookies",
"/cookies.txt", # inside the container (mount your exported cookies here)
"-o",
str(slot_dir / "%(playlist_index)03d - %(title)s [%(id)s].%(ext)s"),
"--yes-playlist",
"--no-overwrites", # keep existing files, only fetch new ones
"-f",
"bv*[height<=1080]+ba/b[height<=1080]/b[height<=1080]/b",
url,
],
check=True,
)
except subprocess.CalledProcessError as e:
slot.state = "error"
state.save()
publish_slot_topics(slot)
mqtt_publish(
TOPIC_INGEST_PROGRESS,
{"slot": slot_num, "state": "error", "message": f"yt-dlp failed: {e}"},
)
return
# --- build .m3u in sorted order ---
media_files = sorted(
[
p
for p in slot_dir.iterdir()
if p.is_file() and p.suffix.lower() in {".mp4", ".mkv", ".webm"}
]
)
if not media_files:
slot.state = "error"
state.save()
publish_slot_topics(slot)
mqtt_publish(
TOPIC_INGEST_PROGRESS,
{"slot": slot_num, "state": "error", "message": "no media files downloaded"},
)
return
item_titles: List[str] = []
m3u_path = slot_dir / "playlist.m3u"
with m3u_path.open("w", encoding="utf-8") as f:
for p in media_files:
# Use a path relative to the slot directory so that if the
# whole slotN folder gets renamed (compaction), the playlist
# still works.
rel = os.path.relpath(p, slot_dir)
f.write(rel + "\n")
item_titles.append(_parse_track_title_from_filename(p))
slot.item_count = len(media_files)
slot.state = "ready"
state.slots[slot_num] = slot
state.save()
# publish MQTT info for HA + StreamDeck
publish_slot_topics(slot)
# items: 1-based index
base = f"{TOPIC_PLAYBACK_BASE}/{slot_num}"
for i, name in enumerate(item_titles, start=1):
mqtt_publish(f"{base}/items/{i}", name, retain=True)
mqtt_publish(
TOPIC_INGEST_PROGRESS,
{"slot": slot_num, "state": "ready", "title": title, "count": slot.item_count},
)
def publish_slot_topics(slot: PlaylistSlot):
"""Publish the title/state/count for one slot to its MQTT topics."""
base = f"{TOPIC_PLAYBACK_BASE}/{slot.slot}"
mqtt_publish(f"{base}/title", slot.title, retain=True)
mqtt_publish(f"{base}/state", slot.state, retain=True)
mqtt_publish(f"{base}/count", slot.item_count, retain=True)
# --- Controller → Player orchestration ---
def handle_play_request(cmd: Dict) -> None:
"""
Handle commands from HA/Companion on TOPIC_CONTROLLER_CMD.
Supported payloads:
- {"action": "play", "slot": N}
- {"action": "play", "playlist": "/some/path.m3u"} # future perm playlist
- {"action": "stop"}
"""
action = cmd.get("action")
# Which slot is currently playing?
playing_slot: Optional[int] = None
for s, slot in state.slots.items():
if slot.state == "playing":
playing_slot = s
break
# --- STOP command ---
if action == "stop":
# Forward stop to player
mqtt_publish(TOPIC_PLAYER_CMD, {"action": "stop"})
# Any slot marked playing goes back to ready
changed = False
for s, slot in state.slots.items():
if slot.state == "playing":
slot.state = "ready"
base = f"{TOPIC_PLAYBACK_BASE}/{s}"
mqtt_publish(f"{base}/state", "ready", retain=True)
changed = True
if changed:
state.save()
return
# --- PLAY command ---
if action != "play":
# ignore unknown for now
return
slot_num_raw = cmd.get("slot")
playlist = cmd.get("playlist") or cmd.get("path")
label = cmd.get("label")
slot_obj: Optional[PlaylistSlot] = None
if slot_num_raw is not None:
try:
slot_num = int(slot_num_raw)
except (TypeError, ValueError):
print(f"[controller] bad slot in play cmd: {slot_num_raw!r}")
return
slot_obj = state.slots.get(slot_num)
if not slot_obj:
print(f"[controller] no such slot {slot_num}")
return
# If we have a slot but no playlist, derive path from the slot.
if slot_obj is not None and not playlist:
playlist = slot_obj.m3u
label = label or slot_obj.title
# If we still have no playlist/path, bail.
if not playlist:
print("[controller] play cmd missing playlist/path and no valid slot provided")
return
# Enforce only one slot at a time for now
if playing_slot is not None and slot_obj is not None and playing_slot != slot_obj.slot:
# briefly mark this requested slot as busy, then restore it
set_slot_busy_temporarily(slot_obj, seconds=5)
mqtt_publish(
TOPIC_INGEST_PROGRESS,
{
"slot": slot_obj.slot,
"state": "busy",
"message": f"slot {playing_slot} already playing; stop first",
},
)
return
# If we're starting a slot-backed playlist, mark that slot as playing
if slot_obj is not None:
slot_obj.state = "playing"
state.save()
base = f"{TOPIC_PLAYBACK_BASE}/{slot_obj.slot}"
mqtt_publish(f"{base}/state", "playing", retain=True)
# Build command for the player
play_cmd = {
"action": "play",
"playlist": playlist,
}
if slot_obj is not None:
play_cmd["slot"] = slot_obj.slot
if label:
play_cmd["label"] = label
mqtt_publish(TOPIC_PLAYER_CMD, play_cmd)
print(f"[controller] sent play cmd to player: {play_cmd}")
# --- HA Discovery ---
HA_DISCOVERY_PREFIX = CONFIG.homeassistant.discovery_prefix
HA_DEVICE = {
"identifiers": ["ytplayer-controller"],
"name": "YouTube Controller",
"manufacturer": "River of Glory",
"model": "YT Controller",
}
def ha_discovery_publish():
"""
Publish MQTT Discovery configs for:
- sensor.yt_slot_<n>_title
- sensor.yt_slot_<n>_state
- sensor.yt_slot_<n>_count
"""
for n in range(1, MAX_SLOTS + 1):
base = f"{TOPIC_PLAYBACK_BASE}/{n}"
obj_id = f"yt_slot_{n}"
# Title
cfg_title = {
"name": f"YT Slot {n} Title",
"state_topic": f"{base}/title",
"unique_id": f"{obj_id}_title",
"icon": "mdi:playlist-music",
"device": HA_DEVICE,
}
mqtt_publish(
f"{HA_DISCOVERY_PREFIX}/sensor/{obj_id}_title/config",
cfg_title,
retain=True,
)
# State (ready/downloading/playing/busy/error/idle)
cfg_state = {
"name": f"YT Slot {n} State",
"state_topic": f"{base}/state",
"unique_id": f"{obj_id}_state",
"icon": "mdi:circle-small",
"device": HA_DEVICE,
}
mqtt_publish(
f"{HA_DISCOVERY_PREFIX}/sensor/{obj_id}_state/config",
cfg_state,
retain=True,
)
# Count
cfg_count = {
"name": f"YT Slot {n} Count",
"state_topic": f"{base}/count",
"unique_id": f"{obj_id}_count",
"icon": "mdi:numeric",
"device": HA_DEVICE,
}
mqtt_publish(
f"{HA_DISCOVERY_PREFIX}/sensor/{obj_id}_count/config",
cfg_count,
retain=True,
)
# --- Public API used by Flask app ---
def enqueue_download(url: str, submitter: Optional[str], note: Optional[str]) -> Dict:
"""
Called by Flask when a new playlist is submitted.
- If this URL is already in a slot, reuse that slot and only grab new videos.
- Otherwise, allocate a new slot.
Returns a small dict summarizing what happened, for UI or HA.
"""
state.load()
state.prune_expired()
# 1) Check if this URL already exists in a slot
existing_slot_obj = None
for sl in state.slots.values():
if sl.url == url:
existing_slot_obj = sl
break
if existing_slot_obj:
slot_num = existing_slot_obj.slot
print(f"[controller] enqueue_download: reusing existing slot {slot_num} for URL {url!r}")
reused = True
else:
# 2) Allocate a new slot
slot_num = state.get_next_free_slot()
reused = False
if slot_num is None:
# queue full
expires = [sl.expires_at for sl in state.slots.values()]
oldest = min(expires) if expires else time.time()
return {
"status": "queue_full",
"oldest_expires_at": oldest,
"oldest_expires_in_seconds": int(oldest - time.time()),
}
# 3) Spawn background thread so Flask doesn't block
t = threading.Thread(
target=_download_playlist_to_slot,
args=(url, slot_num),
daemon=True,
)
t.start()
# 4) Simple MQTT log of submission
mqtt_publish(
TOPIC_INGEST_PROGRESS,
{
"slot": slot_num,
"state": "queued",
"url": url,
"submitter": submitter,
"note": note,
"reused_slot": reused,
},
)
return {
"status": "queued",
"slot": slot_num,
"reused_slot": reused,
}
# --- Explicit lifecycle (imports are intentionally side-effect free) ---
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
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