Files
SandTable/playControl.py
2026-08-08 16:11:57 +03:00

127 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""
Shared play/pause/stop/skip control for the sand table player.
Replaces the old OS-signal-based approach (SIGUSR1/SIGUSR2/SIGTERM caught
by a handler with module-global flags, process discovery via psutil
scanning cmdlines for "PlayQueue.py"). That worked but had some real
downsides:
- views.py's "playing" flag was a plain Python global, so with more than
one web worker process it could disagree with reality.
- "is a player running" was answered by grepping every process's cmdline
for a string, which is a little fragile.
- Signals give no visibility into *what* state the player is actually in
(playing/paused/idle) or which track - only views.py's own guess.
This module stores that state in a single row of a small SQLite table
instead - the same DB the rest of the project already uses. The running
player (playTrack.py) polls `get_command()` between points; the web app
(views.py) writes to it with `set_command()` and reads real status with
`get_status()`. No signals, no process-name matching for control - psutil
is only still used as a safety net to force-kill a stuck process by its
recorded PID.
"""
import sqlite3
import psutil
DB_PATH = "/home/pi/SandTable/db.sqlite3"
TABLE = "sandtable_control"
# status: what the player is actually doing right now
STATUS_IDLE = "idle"
STATUS_PLAYING = "playing"
STATUS_PAUSED = "paused"
# command: what the player has been asked to do - it's expected to notice
# and react next time it checks
COMMAND_PLAY = "play"
COMMAND_PAUSE = "pause"
COMMAND_STOP = "stop"
COMMAND_NEXT = "next"
def _connect():
conn = sqlite3.connect(DB_PATH)
conn.execute(
f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id INTEGER PRIMARY KEY CHECK (id = 0),
command TEXT NOT NULL DEFAULT '{COMMAND_PLAY}',
status TEXT NOT NULL DEFAULT '{STATUS_IDLE}',
current_track TEXT,
pid INTEGER
)
"""
)
conn.execute(
f"INSERT OR IGNORE INTO {TABLE} (id, command, status) VALUES (0, ?, ?)",
(COMMAND_PLAY, STATUS_IDLE),
)
return conn
def init_control_table():
conn = _connect()
conn.commit()
conn.close()
def set_command(command):
conn = _connect()
conn.execute(f"UPDATE {TABLE} SET command = ? WHERE id = 0", (command,))
conn.commit()
conn.close()
def get_command():
conn = _connect()
row = conn.execute(f"SELECT command FROM {TABLE} WHERE id = 0").fetchone()
conn.close()
return row[0] if row else COMMAND_PLAY
def set_status(status, current_track=None, pid=None):
"""current_track/pid are only updated when explicitly passed - pass
current_track='' or pid=0 if you actually want to clear them."""
conn = _connect()
if current_track is not None:
conn.execute(f"UPDATE {TABLE} SET current_track = ? WHERE id = 0", (current_track,))
if pid is not None:
conn.execute(f"UPDATE {TABLE} SET pid = ? WHERE id = 0", (pid,))
conn.execute(f"UPDATE {TABLE} SET status = ? WHERE id = 0", (status,))
conn.commit()
conn.close()
def get_status():
conn = _connect()
row = conn.execute(f"SELECT status, current_track, pid FROM {TABLE} WHERE id = 0").fetchone()
conn.close()
if row is None:
return {"status": STATUS_IDLE, "current_track": None, "pid": None}
return {"status": row[0], "current_track": row[1], "pid": row[2]}
def stop_player(hard=True):
"""Ask the running player to stop, and (by default) also force-kill its
process by the PID it registered - useful right before a manual
home/stop/shutdown, where you don't want to wait for the cooperative
stop to be noticed. Returns True if a live player was found."""
status = get_status()
pid = status.get("pid")
found = False
if pid and psutil.pid_exists(pid):
found = True
if hard:
try:
psutil.Process(pid).terminate()
except psutil.NoSuchProcess:
pass
set_command(COMMAND_STOP)
set_status(STATUS_IDLE, pid=0)
return found