BAPSicle/player.py

648 lines
21 KiB
Python
Raw Normal View History

2020-11-01 02:35:14 +00:00
"""
BAPSicle Server
Next-gen audio playout server for University Radio York playout,
based on WebStudio interface.
Audio Player
Authors:
Matthew Stratford
Michael Grace
Date:
October, November 2020
"""
# This is the player. Reliability is critical here, so we're catching
# literally every exception possible and handling it.
# It is key that whenever the parent server tells us to do something
# that we respond with something, FAIL or OKAY. The server doesn't like to be kept waiting.
2020-12-19 14:57:37 +00:00
from helpers.types import PlayerState, RepeatMode
2020-10-29 21:23:37 +00:00
from queue import Empty
import multiprocessing
import setproctitle
import copy
import json
import time
2020-11-03 22:52:58 +00:00
2020-12-19 14:57:37 +00:00
from typing import Any, Callable, Dict, List, Optional
2020-11-03 22:52:58 +00:00
from plan import PlanItem
2020-11-03 22:52:58 +00:00
# Stop the Pygame Hello message.
import os
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
from pygame import mixer, NOEVENT, USEREVENT, event, init
2020-10-25 01:23:24 +00:00
from mutagen.mp3 import MP3
from helpers.myradio_api import MyRadioAPI
2020-11-05 19:06:16 +00:00
from helpers.os_environment import isMacOS
from helpers.state_manager import StateManager
from helpers.logging_manager import LoggingManager
2020-10-23 20:10:32 +00:00
PLAYBACK_END = USEREVENT + 1
# TODO ENUM
VALID_MESSAGE_SOURCES = ["WEBSOCKET", "UI", "CONTROLLER", "ALL"]
2020-10-24 20:31:52 +00:00
class Player():
out_q: multiprocessing.Queue
last_msg: str
last_msg_source: str
last_time_update = None
state: StateManager
logger: LoggingManager
api: MyRadioAPI
running: bool = False
already_stopped: bool = False
starting: bool = False
2020-10-24 20:31:52 +00:00
__default_state = {
2020-10-30 00:32:34 +00:00
"initialised": False,
2020-11-01 02:35:14 +00:00
"loaded_item": None,
2020-10-24 20:31:52 +00:00
"channel": -1,
"playing": False,
"paused": False,
2020-10-29 22:25:17 +00:00
"loaded": False,
2020-10-24 20:31:52 +00:00
"pos": 0,
"pos_offset": 0,
"pos_true": 0,
2020-10-24 20:31:52 +00:00
"remaining": 0,
"length": 0,
"auto_advance": True,
"repeat": "none", # none, one or all
"play_on_load": False,
2020-11-01 02:35:14 +00:00
"output": None,
"show_plan": []
2020-10-24 20:31:52 +00:00
}
__rate_limited_params = [
"pos",
"pos_offset",
"pos_true",
"remaining"
]
2020-10-29 22:25:17 +00:00
@property
2020-10-24 20:31:52 +00:00
def isInit(self):
try:
2020-10-25 01:23:24 +00:00
mixer.music.get_busy()
2020-10-24 20:31:52 +00:00
except:
return False
2020-10-29 22:25:17 +00:00
return True
2020-10-24 20:31:52 +00:00
2020-10-29 21:23:37 +00:00
@property
2020-10-24 20:31:52 +00:00
def isPlaying(self):
2020-10-29 22:25:17 +00:00
if self.isInit:
return (not self.isPaused) and bool(mixer.music.get_busy())
2020-10-29 22:25:17 +00:00
return False
2020-10-24 20:31:52 +00:00
@property
2020-12-19 14:57:37 +00:00
def isPaused(self) -> bool:
return self.state.state["paused"]
2020-10-29 21:23:37 +00:00
@property
def isLoaded(self):
2020-11-01 02:35:14 +00:00
if not self.state.state["loaded_item"]:
2020-10-29 22:25:17 +00:00
return False
if self.isPlaying:
return True
2020-10-30 00:32:34 +00:00
# Because Pygame/SDL is annoying
# We're not playing now, so we can quickly test run
# If that works, we're loaded.
2020-10-29 22:25:17 +00:00
try:
2020-12-19 14:57:37 +00:00
position: float = self.state.state["pos"]
2020-10-30 00:32:34 +00:00
mixer.music.set_volume(0)
mixer.music.play(0)
2020-10-29 22:25:17 +00:00
except:
2020-10-30 00:32:34 +00:00
try:
mixer.music.set_volume(1)
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to reset volume after attempting loaded test.")
2020-10-30 00:32:34 +00:00
pass
2020-10-29 22:25:17 +00:00
return False
2020-10-30 00:32:34 +00:00
if position > 0:
self.pause()
2020-10-30 00:32:34 +00:00
else:
self.stop()
2020-10-30 00:32:34 +00:00
mixer.music.set_volume(1)
return True
2020-10-29 22:25:17 +00:00
2020-10-30 00:32:34 +00:00
@property
def status(self):
2020-11-01 02:35:14 +00:00
state = copy.copy(self.state.state)
# Not the biggest fan of this, but maybe I'll get a better solution for this later
state["loaded_item"] = state["loaded_item"].__dict__ if state["loaded_item"] else None
state["show_plan"] = [repr.__dict__ for repr in state["show_plan"]]
2020-11-01 02:35:14 +00:00
res = json.dumps(state)
2020-10-30 00:32:34 +00:00
return res
2020-10-29 21:23:37 +00:00
2020-11-15 19:34:13 +00:00
# Audio Playout Related Methods
2020-12-19 14:57:37 +00:00
def play(self, pos: float = 0):
2021-03-15 20:19:45 +00:00
if not self.isLoaded:
return
global starting
global already_stopped
starting = True
try:
mixer.music.play(0, pos)
mixer.music.set_endevent(PLAYBACK_END)
self.state.update("pos_offset", pos)
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to play at pos: " + str(pos))
return False
self.state.update("paused", False)
already_stopped = False
return True
2020-10-24 20:31:52 +00:00
def pause(self):
try:
mixer.music.pause()
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to pause.")
return False
self.state.update("paused", True)
return True
2020-10-24 20:31:52 +00:00
def unpause(self):
2020-10-29 22:25:17 +00:00
if not self.isPlaying:
2020-12-19 14:57:37 +00:00
position: float = self.state.state["pos_true"]
2020-10-29 22:25:17 +00:00
try:
2020-11-03 22:48:11 +00:00
self.play(position)
2020-10-29 22:25:17 +00:00
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to unpause from pos: " + str(position))
2020-10-29 22:25:17 +00:00
return False
self.state.update("paused", False)
2020-10-29 22:25:17 +00:00
return True
return False
2020-10-24 20:31:52 +00:00
def stop(self):
# if self.isPlaying or self.isPaused:
try:
mixer.music.stop()
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to stop playing.")
return False
self.state.update("pos", 0)
self.state.update("pos_offset", 0)
self.state.update("pos_true", 0)
self.state.update("paused", False)
global already_stopped
already_stopped = True
return True
# return False
2020-10-24 20:31:52 +00:00
2020-12-19 14:57:37 +00:00
def seek(self, pos: float) -> bool:
2020-10-29 22:25:17 +00:00
if self.isPlaying:
try:
self.play(pos)
2020-10-29 22:25:17 +00:00
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to seek to pos: " + str(pos))
2020-10-29 22:25:17 +00:00
return False
return True
else:
self.state.update("paused", True)
self._updateState(pos=pos)
return True
2020-10-24 20:31:52 +00:00
def set_auto_advance(self, message: bool) -> bool:
self.state.update("auto_advance", message)
return True
def set_repeat(self, message: str) -> bool:
if message in ["all", "one", "none"]:
self.state.update("repeat", message)
return True
else:
return False
def set_play_on_load(self, message: bool) -> bool:
self.state.update("play_on_load", message)
return True
2020-11-15 19:34:13 +00:00
# Show Plan Related Methods
def get_plan(self, message: int):
plan = self.api.get_showplan(message)
self.clear_channel_plan()
channel = self.state.state["channel"]
2021-03-13 22:32:04 +00:00
self.logger.log.info(plan)
if len(plan) > channel:
for plan_item in plan[str(channel)]:
try:
self.add_to_plan(plan_item)
except Exception as e:
self.logger.log.critical("Failed to add item to show plan: {}".format(e))
continue
return True
2020-12-19 14:57:37 +00:00
def add_to_plan(self, new_item: Dict[str, Any]) -> bool:
2021-03-13 22:32:04 +00:00
new_item_obj = PlanItem(new_item)
plan_copy: List[PlanItem] = copy.copy(self.state.state["show_plan"])
# Shift any plan items after the new position down one to make space.
for item in plan_copy:
if item.weight >= new_item_obj.weight:
item.weight += 1
plan_copy += [new_item_obj] # Add the new item.
def sort_weight(e: PlanItem):
return e.weight
plan_copy.sort(key=sort_weight) # Sort into weighted order.
self.state.update("show_plan", plan_copy)
2020-11-01 02:35:14 +00:00
return True
def remove_from_plan(self, weight: int) -> bool:
2020-12-19 14:57:37 +00:00
plan_copy: List[PlanItem] = copy.copy(self.state.state["show_plan"])
2021-03-13 22:32:04 +00:00
found = False
2020-12-19 14:57:37 +00:00
for i in plan_copy:
if i.weight == weight:
2020-11-02 23:06:45 +00:00
plan_copy.remove(i)
2021-03-13 22:32:04 +00:00
found = True
elif i.weight > weight: # Shuffle up the weights of the items following the deleted one.
i.weight -= 1
if found:
self.state.update("show_plan", plan_copy)
return True
2020-11-02 23:06:45 +00:00
return False
def clear_channel_plan(self) -> bool:
self.state.update("show_plan", [])
return True
def load(self, weight: int):
2020-10-29 22:25:17 +00:00
if not self.isPlaying:
self.unload()
2020-10-29 22:25:17 +00:00
showplan = self.state.state["show_plan"]
2020-12-19 14:57:37 +00:00
loaded_item: Optional[PlanItem] = None
for i in range(len(showplan)):
if showplan[i].weight == weight:
loaded_item = showplan[i]
2020-11-01 02:35:14 +00:00
break
2020-12-19 14:57:37 +00:00
if loaded_item == None:
self.logger.log.error("Failed to find weight: {}".format(weight))
2020-11-01 02:35:14 +00:00
return False
if (loaded_item.filename == "" or loaded_item.filename == None):
loaded_item.filename = self.api.get_filename(item = loaded_item)
2020-12-19 14:57:37 +00:00
if not loaded_item.filename:
return False
self.state.update("loaded_item", loaded_item)
2020-10-29 22:25:17 +00:00
for i in range(len(showplan)):
if showplan[i].weight == weight:
self.state.update("show_plan", index=i, value=loaded_item)
break
# TODO: Update the show plan filenames
2020-10-29 22:25:17 +00:00
try:
self.logger.log.info("Loading file: " + str(loaded_item.filename))
mixer.music.load(loaded_item.filename)
2020-10-29 22:25:17 +00:00
except:
# We couldn't load that file.
self.logger.log.exception("Couldn't load file: " + str(loaded_item.filename))
2020-10-29 22:25:17 +00:00
return False
try:
2020-12-19 14:57:37 +00:00
if ".mp3" in loaded_item.filename:
song = MP3(loaded_item.filename)
2020-10-29 22:25:17 +00:00
self.state.update("length", song.info.length)
else:
2020-12-19 14:57:37 +00:00
self.state.update("length", mixer.Sound(loaded_item.filename).get_length()/1000)
2020-10-29 22:25:17 +00:00
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to update the length of item.")
2020-10-29 22:25:17 +00:00
return False
if self.state.state["play_on_load"]:
self.play()
2020-10-29 22:25:17 +00:00
return True
def unload(self):
if not self.isPlaying:
try:
mixer.music.unload()
self.state.update("paused", False)
2020-11-01 02:35:14 +00:00
self.state.update("loaded_item", None)
2020-10-29 22:25:17 +00:00
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to unload channel.")
2020-10-29 22:25:17 +00:00
return False
return not self.isLoaded
2020-10-24 20:31:52 +00:00
def quit(self):
2020-11-03 22:48:11 +00:00
try:
mixer.quit()
self.state.update("paused", False)
self.logger.log.info("Quit mixer.")
2020-11-03 22:48:11 +00:00
except:
self.logger.log.exception("Failed to quit mixer.")
2020-10-24 20:31:52 +00:00
2020-12-19 14:57:37 +00:00
def output(self, name: Optional[str] = None):
2020-11-01 03:19:21 +00:00
wasPlaying = self.state.state["playing"]
name = None if name == "none" else name
2020-10-24 20:31:52 +00:00
self.quit()
2020-10-30 00:32:34 +00:00
self.state.update("output", name)
2020-10-24 20:31:52 +00:00
try:
if name:
2020-11-01 03:27:26 +00:00
mixer.init(44100, -16, 2, 1024, devicename=name)
2020-10-24 20:31:52 +00:00
else:
2020-11-01 03:27:26 +00:00
mixer.init(44100, -16, 2, 1024)
2020-10-24 20:31:52 +00:00
except:
2020-11-03 22:48:11 +00:00
self.logger.log.exception("Failed to init mixer with device name: " + str(name))
2020-10-30 00:32:34 +00:00
return False
2020-10-24 20:31:52 +00:00
loadedItem = self.state.state["loaded_item"]
if (loadedItem):
self.load(loadedItem.weight)
2020-11-01 03:19:21 +00:00
if wasPlaying:
self.unpause()
2020-10-30 00:32:34 +00:00
return True
2020-10-24 20:31:52 +00:00
def ended(self):
loaded_item = self.state.state["loaded_item"]
# check the existing state (not self.isPlaying)
# Since this is called multiple times when pygame isn't playing.
global starting
if starting:
print("Starting")
starting = False
return
global already_stopped
#print(already_stopped, self.state.state["remaining"], self.isPlaying)
if loaded_item == None or already_stopped or (self.state.state["remaining"] > 1):
return
mixer.music.set_endevent(NOEVENT)
already_stopped = True
stopping = True
# Track has ended
print("Finished", loaded_item.name)
# Repeat 1
if self.state.state["repeat"] == "ONE":
self.play()
stopping = False
# Auto Advance
elif self.state.state["auto_advance"]:
for i in range(len(self.state.state["show_plan"])):
if self.state.state["show_plan"][i].weight == loaded_item.weight:
if len(self.state.state["show_plan"]) > i+1:
self.load(self.state.state["show_plan"][i+1].weight)
break
# Repeat All
elif self.state.state["repeat"] == "ALL":
self.load(self.state.state["show_plan"][0].weight)
# Play on Load
if self.state.state["play_on_load"]:
self.play()
stopping = False
if stopping:
self.stop()
if self.out_q:
self._retAll("STOPPED") # Tell clients that we've stopped playing.
2020-12-19 14:57:37 +00:00
def _updateState(self, pos: Optional[float] = None):
2020-10-30 00:32:34 +00:00
self.state.update("initialised", self.isInit)
if self.isInit:
if (pos):
self.state.update("pos", max(0, pos))
elif self.isPlaying:
# Get one last update in, incase we're about to pause/stop it.
self.state.update("pos", max(0, mixer.music.get_pos()/1000))
elif not self.isPaused:
self.state.update("pos", 0) # Reset back to 0 if stopped.
self.state.update("pos_offset", 0)
2020-10-30 00:32:34 +00:00
self.state.update("playing", self.isPlaying)
self.state.update("loaded", self.isLoaded)
self.state.update("pos_true", self.state.state["pos"] + self.state.state["pos_offset"])
self.state.update("remaining", self.state.state["length"] - self.state.state["pos_true"])
2020-10-30 00:32:34 +00:00
def _ping_times(self):
UPDATES_FREQ_SECS = 0.2
if self.last_time_update == None or self.last_time_update + UPDATES_FREQ_SECS < time.time():
self.last_time_update = time.time()
self._retAll("POS:" + str(int(self.state.state["pos_true"])))
2020-11-05 18:58:18 +00:00
def _retAll(self, msg):
self.out_q.put("ALL:" + msg)
def _retMsg(self, msg: Any, okay_str: bool = False, custom_prefix: Optional[str] = None):
# Make sure to add the message source back, so that it can be sent to the correct destination in the main server.
if custom_prefix:
response = custom_prefix
else:
response = "{}:{}:".format(self.last_msg_source, self.last_msg)
2020-10-30 00:32:34 +00:00
if msg == True:
response += "OKAY"
elif isinstance(msg, str):
if okay_str:
response += "OKAY:" + msg
else:
response += "FAIL:" + msg
2020-10-24 20:31:52 +00:00
else:
2020-10-30 00:32:34 +00:00
response += "FAIL"
self.logger.log.debug(("Preparing to send: {}".format(response)))
2020-10-30 00:32:34 +00:00
if self.out_q:
self.logger.log.info(("Sending: {}".format(response)))
2020-10-30 00:32:34 +00:00
self.out_q.put(response)
2020-10-24 20:31:52 +00:00
def _send_status(self):
# TODO This is hacky
self._retMsg(str(self.status),okay_str=True,custom_prefix="ALL:STATUS:")
2020-12-19 14:57:37 +00:00
def __init__(self, channel: int, in_q: multiprocessing.Queue, out_q: multiprocessing.Queue):
2020-10-30 00:32:34 +00:00
process_title = "Player: Channel " + str(channel)
setproctitle.setproctitle(process_title)
multiprocessing.current_process().name = process_title
2020-10-24 20:31:52 +00:00
# Init pygame, only used really for the end of playback trigger.
#init()
self.running = True
self.out_q = out_q
2020-10-24 20:31:52 +00:00
self.logger = LoggingManager("Player" + str(channel))
self.api = MyRadioAPI(self.logger)
self.state = StateManager("Player" + str(channel), self.logger,
2020-11-15 19:34:13 +00:00
self.__default_state, self.__rate_limited_params)
self.state.add_callback(self._send_status)
2020-10-30 23:59:58 +00:00
self.state.update("channel", channel)
2020-10-24 20:31:52 +00:00
loaded_state = copy.copy(self.state.state)
if loaded_state["output"]:
2020-12-19 14:57:37 +00:00
self.logger.log.info("Setting output to: " + str(loaded_state["output"]))
2020-10-24 20:31:52 +00:00
self.output(loaded_state["output"])
else:
self.logger.log.info("Using default output device.")
2020-10-24 20:31:52 +00:00
self.output()
2020-12-19 14:57:37 +00:00
loaded_item = loaded_state["loaded_item"]
if loaded_item:
self.logger.log.info("Loading filename: " + str(loaded_item.filename))
self.load(loaded_item.weight)
2020-10-24 20:31:52 +00:00
if loaded_state["pos_true"] != 0:
self.logger.log.info("Seeking to pos_true: " + str(loaded_state["pos_true"]))
self.seek(loaded_state["pos_true"])
2020-10-24 20:31:52 +00:00
2020-10-29 21:23:37 +00:00
if loaded_state["playing"] == True:
self.logger.log.info("Resuming.")
2020-10-29 21:23:37 +00:00
self.unpause()
else:
self.logger.log.info("No file was previously loaded.")
2020-10-24 20:31:52 +00:00
try:
while self.running:
2021-04-04 22:14:08 +00:00
time.sleep(0.02)
self._updateState()
self._ping_times()
2020-10-29 21:23:37 +00:00
try:
message = in_q.get_nowait()
source = message.split(":")[0]
if source not in VALID_MESSAGE_SOURCES:
self.last_msg_source = ""
self.last_msg = ""
self.logger.log.warn("Message from unknown sender source: {}".format(source))
continue
self.last_msg_source = source
self.last_msg = message.split(":", 1)[1]
self.logger.log.info("Recieved message from source {}: {}".format(self.last_msg_source, self.last_msg))
2020-10-29 21:23:37 +00:00
except Empty:
# The incomming message queue was empty,
# skip message processing
pass
else:
2020-10-30 00:32:34 +00:00
2020-10-29 21:23:37 +00:00
# We got a message.
2020-10-29 22:25:17 +00:00
2020-10-30 00:32:34 +00:00
# Output re-inits the mixer, so we can do this any time.
if (self.last_msg.startswith("OUTPUT")):
split = self.last_msg.split(":")
self._retMsg(self.output(split[1]))
elif self.isInit:
2020-12-19 14:57:37 +00:00
message_types: Dict[str, Callable[..., Any]] = { # TODO Check Types
"STATUS": lambda: self._retMsg(self.status, True),
2020-11-05 18:58:18 +00:00
# Audio Playout
2020-11-15 19:34:13 +00:00
"PLAY": lambda: self._retMsg(self.play()),
"PAUSE": lambda: self._retMsg(self.pause()),
"UNPAUSE": lambda: self._retMsg(self.unpause()),
"STOP": lambda: self._retMsg(self.stop()),
"SEEK": lambda: self._retMsg(self.seek(float(self.last_msg.split(":")[1]))),
"AUTOADVANCE": lambda: self._retMsg(self.set_auto_advance((self.last_msg.split(":")[1] == "True"))),
2020-11-15 19:34:13 +00:00
"REPEAT": lambda: self._retMsg(self.set_repeat(self.last_msg.split(":")[1])),
"PLAYONLOAD": lambda: self._retMsg(self.set_play_on_load((self.last_msg.split(":")[1] == "True"))),
# Show Plan Items
"GET_PLAN": lambda: self._retMsg(self.get_plan(int(self.last_msg.split(":")[1]))),
2020-11-15 19:34:13 +00:00
"LOAD": lambda: self._retMsg(self.load(int(self.last_msg.split(":")[1]))),
"LOADED?": lambda: self._retMsg(self.isLoaded),
"UNLOAD": lambda: self._retMsg(self.unload()),
"ADD": lambda: self._retMsg(self.add_to_plan(json.loads(":".join(self.last_msg.split(":")[1:])))),
"REMOVE": lambda: self._retMsg(self.remove_from_plan(int(self.last_msg.split(":")[1]))),
"CLEAR": lambda: self._retMsg(self.clear_channel_plan())
2020-11-01 02:37:56 +00:00
}
message_type: str = self.last_msg.split(":")[0]
if message_type in message_types.keys():
message_types[message_type]()
2020-10-30 00:32:34 +00:00
2020-10-30 00:32:34 +00:00
elif (self.last_msg == 'QUIT'):
2020-10-29 21:23:37 +00:00
self.running = False
2020-10-30 00:32:34 +00:00
continue
else:
self._retMsg("Unknown Command")
else:
2020-10-30 00:32:34 +00:00
if (self.last_msg == 'STATUS'):
self._retMsg(self.status)
else:
self._retMsg(False)
2020-10-29 21:23:37 +00:00
try:
callback_event = event.poll()
if callback_event.type == PLAYBACK_END:
self.ended()
else:
pass
except Exception as e:
pass
# Catch the player being killed externally.
except KeyboardInterrupt:
self.logger.log.info("Received KeyboardInterupt")
except SystemExit:
self.logger.log.info("Received SystemExit")
except Exception as e:
self.logger.log.exception("Received unexpected exception: {}".format(e))
2020-10-24 20:31:52 +00:00
self.logger.log.info("Quiting player " + str(channel))
2020-10-29 21:23:37 +00:00
self.quit()
self._retAll("EXIT")
del self.logger
os._exit(0)
2020-10-29 21:23:37 +00:00
if __name__ == "__main__":
raise Exception("This BAPSicle Player is a subcomponenet, it will not run individually.")