Merge remote-tracking branch 'origin/dev' into debugging

This commit is contained in:
Matthew Stratford 2020-11-04 20:33:09 +00:00
commit a21d93894d
No known key found for this signature in database
GPG key ID: 9E53C8B3F0B57395
7 changed files with 364 additions and 57 deletions

10
.gitignore vendored
View file

@ -1,5 +1,5 @@
.vscode/settings.json
__pycache__/
@ -10,6 +10,7 @@ state/
build/build-exe-config.json
install/*.exe
install/nssm
*.pyo
@ -21,6 +22,13 @@ build/build/BAPSicle/
build/output/
venv/
config.py
dev/welcome.mp3
build/build-exe-pyinstaller-command.sh
logs/

View file

@ -3,4 +3,5 @@ flask
mutagen
sounddevice
autopep8
setproctitle
setproctitle
pyttsx3

6
config.py.example Normal file
View file

@ -0,0 +1,6 @@
# Flask Details
HOST: str = "localhost"
PORT: int = 13500
# BAPSicle Details
VERSION: float = 1.0

View file

@ -42,10 +42,22 @@ class StateManager:
self.__state_in_file = copy(self.state)
else:
try:
self.__state = json.loads(file_state)
self.state = json.loads(file_state)
# Turn from JSON -> PlanObject
self.update(
"loaded_item",
PlanObject(self.__state["loaded_item"]) if self.state["loaded_item"] else None
)
self.update(
"show_plan",
[PlanObject(obj) for obj in self.state["show_plan"]]
)
except:
self._logException("Failed to parse state JSON. Resetting to default state.")
self.state = default_state
self.state = copy(default_state)
self.__state_in_file = copy(self.state)
# Now setup the rate limiting
# Essentially rate limit all values to "now" to start with, allowing the first update
@ -70,19 +82,22 @@ class StateManager:
self.__state_in_file = state
# Make sure we're not manipulating state
state = copy(state)
state_to_json = copy(state)
now = datetime.now()
current_time = now.strftime("%H:%M:%S")
state["last_updated"] = current_time
state_to_json["last_updated"] = current_time
# Not the biggest fan of this, but maybe I'll get a better solution for this later
state_to_json["loaded_item"] = state_to_json["loaded_item"].__dict__ if state_to_json["loaded_item"] else None
state_to_json["show_plan"] = [repr.__dict__ for repr in state_to_json["show_plan"]]
try:
state_json = json.dumps(state, indent=2, sort_keys=True)
state_json = json.dumps(state_to_json, indent=2, sort_keys=True)
except:
self._logException("Failed to dump JSON state.")
else:
with open(self.filepath, "w") as file:
file.write(state_json)
def update(self, key, value):
@ -98,7 +113,7 @@ class StateManager:
state_to_update = self.state
if state_to_update[key] == value:
if key in state_to_update and state_to_update[key] == value:
# We're trying to update the state with the same value.
# In this case, ignore the update
return

57
plan.py Normal file
View file

@ -0,0 +1,57 @@
"""
BAPSicle Server
Next-gen audio playout server for University Radio York playout,
based on WebStudio interface.
Show Plan Items
Authors:
Michael Grace
Date:
November 2020
"""
from typing import Dict
import os
class PlanObject:
_timeslotitemid: int = 0
_filename: str = ""
_title: str = ""
_artist: str = ""
@property
def timeslotitemid(self) -> int:
return self._timeslotitemid
@property
def filename(self) -> str:
return self._filename
@property
def name(self) -> str:
return "{0} - {1}".format(self._title, self._artist) if self._artist else self._title
@property
def __dict__(self) -> Dict[str, any]:
return {
"timeslotitemid": self.timeslotitemid,
"title": self._title,
"artist": self._artist,
"name": self.name,
"filename": self.filename
}
def __init__(self, new_item: Dict[str, any]):
self._timeslotitemid = new_item["timeslotitemid"]
self._filename = new_item["filename"]
self._title = new_item["title"]
self._artist = new_item["artist"]
# Fix any OS specific / or \'s
if os.path.sep == "/":
self._filename = self.filename.replace("\\", '/')
else:
self._filename = self.filename.replace("/", '\\')

198
player.py
View file

@ -1,3 +1,18 @@
"""
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.
@ -11,6 +26,11 @@ import copy
import json
import time
import sys
from typing import Callable, Dict, List
from plan import PlanObject
# Stop the Pygame Hello message.
import os
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
@ -20,6 +40,8 @@ from mutagen.mp3 import MP3
from helpers.state_manager import StateManager
from helpers.logging_manager import LoggingManager
class Player():
state = None
running = False
@ -29,7 +51,7 @@ class Player():
__default_state = {
"initialised": False,
"filename": "",
"loaded_item": None,
"channel": -1,
"playing": False,
"paused": False,
@ -39,8 +61,11 @@ class Player():
"pos_true": 0,
"remaining": 0,
"length": 0,
"loop": False,
"output": None
"auto_advance": True,
"repeat": "NONE", #NONE, ONE or ALL
"play_on_load": False,
"output": None,
"show_plan": []
}
__rate_limited_params = [
@ -71,7 +96,7 @@ class Player():
@property
def isLoaded(self):
if not self.state.state["filename"]:
if not self.state.state["loaded_item"]:
return False
if self.isPlaying:
return True
@ -99,9 +124,17 @@ class Player():
@property
def status(self):
res = json.dumps(self.state.state)
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"]]
res = json.dumps(state)
return res
### Audio Playout Related Methods
def play(self, pos=0):
try:
mixer.music.play(0, pos)
@ -160,16 +193,70 @@ class Player():
self._updateState(pos=pos)
return True
def load(self, filename):
def set_auto_advance(self, message: int) -> bool:
if message == 0:
self.state.update("auto_advance", False)
return True # It did it
elif message == 1:
self.state.update("auto_advance", True)
return True
else:
return False
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: int) -> bool:
if message == 0:
self.state.update("play_on_load", False)
return True # It did it
elif message == 1:
self.state.update("play_on_load", True)
return True
else:
return False
### Show Plan Related Methods
def add_to_plan(self, new_item: Dict[str, any]) -> bool:
self.state.update("show_plan", self.state.state["show_plan"] + [PlanObject(new_item)])
return True
def remove_from_plan(self, timeslotitemid: int) -> bool:
plan_copy = copy.copy(self.state.state["show_plan"])
for i in range(len(plan_copy)):
if plan_copy[i].timeslotitemid == timeslotitemid:
plan_copy.remove(i)
self.state.update("show_plan", plan_copy)
return True
return False
def clear_channel_plan(self) -> bool:
self.state.update("show_plan", [])
return True
def load(self, timeslotitemid: int):
if not self.isPlaying:
self.unload()
# Fix any OS specific / or \'s
if os.path.sep == "/":
filename = filename.replace("\\", '/')
else:
filename = filename.replace("/", '\\')
self.state.update("filename", filename)
updated: bool = False
for i in range(len(self.state.state["show_plan"])):
if self.state.state["show_plan"][i].timeslotitemid == timeslotitemid:
self.state.update("loaded_item", self.state.state["show_plan"][i])
updated = True
break
if not updated:
print("Failed to find timeslotitemid:", timeslotitemid)
return False
filename: str = self.state.state["loaded_item"].filename
try:
self.logger.log.info("Loading file: " + str(filename))
@ -195,7 +282,7 @@ class Player():
try:
mixer.music.unload()
self.state.update("paused", False)
self.state.update("filename", "")
self.state.update("loaded_item", None)
except:
self.logger.log.exception("Failed to unload channel.")
return False
@ -212,6 +299,7 @@ class Player():
wasPlaying = self.state.state["playing"]
self.quit()
self.state.update("output", name)
self.state.update("loaded_item", None)
try:
if name:
mixer.init(44100, -16, 2, 1024, devicename=name)
@ -221,8 +309,6 @@ class Player():
self.logger.log.exception("Failed to init mixer with device name: " + str(name))
return False
self.load(self.state.state["filename"])
if wasPlaying:
self.unpause()
@ -246,6 +332,31 @@ class Player():
self.state.update("remaining", self.state.state["length"] - self.state.state["pos_true"])
if self.state.state["remaining"] == 0 and self.state.state["loaded_item"]:
# Track has ended
print("Finished", self.state.state["loaded_item"].name)
# Repeat 1
if self.state.state["repeat"] == "ONE":
self.play()
# 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].timeslotitemid == self.state.state["loaded_item"].timeslotitemid:
if len(self.state.state["show_plan"]) > i+1:
self.load(self.state.state["show_plan"][i+1].timeslotitemid)
break
# Repeat All
elif self.state.state["repeat"] == "ALL":
self.load(self.state.state["show_plan"][0].timeslotitemid)
# Play on Load
if self.state.state["play_on_load"]:
self.play()
def _retMsg(self, msg, okay_str=False):
response = self.last_msg + ":"
if msg == True:
@ -283,9 +394,9 @@ class Player():
self.logger.log.info("Using default output device.")
self.output()
if loaded_state["filename"]:
self.logger.log.info("Loading filename: " + loaded_state["filename"])
self.load(loaded_state["filename"])
if loaded_state["loaded_item"]:
self.logger.log.info("Loading filename: " + loaded_state["loaded_item"].filename)
self.load(loaded_state["loaded_item"].timeslotitemid)
if loaded_state["pos_true"] != 0:
self.logger.log.info("Seeking to pos_true: " + str(loaded_state["pos_true"]))
@ -318,40 +429,37 @@ class Player():
elif self.isInit:
if (self.last_msg == 'LOADED?'):
self._retMsg(self.isLoaded)
continue
message_types: Dict[str, Callable[any, bool]] = { # TODO Check Types
"STATUS": lambda: self._retMsg(self.status, True),
# Audio Playout
"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(int(self.last_msg.split(":")[1]))),
"REPEAT": lambda: self._retMsg(self.set_repeat(self.last_msg.split(":")[1])),
"PLAYONLOAD": lambda: self._retMsg(self.set_play_on_load(int(self.last_msg.split(":")[1]))),
elif (self.last_msg == 'PLAY'):
self._retMsg(self.play())
# Show Plan Items
"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())
}
elif (self.last_msg == 'PAUSE'):
self._retMsg(self.pause())
message_type: str = self.last_msg.split(":")[0]
elif (self.last_msg == 'UNPAUSE'):
self._retMsg(self.unpause())
elif (self.last_msg == 'STOP'):
self._retMsg(self.stop())
if message_type in message_types.keys():
message_types[message_type]()
elif (self.last_msg == 'QUIT'):
self.running = False
continue
elif (self.last_msg.startswith("SEEK")):
split = self.last_msg.split(":")
self._retMsg(self.seek(float(split[1])))
elif (self.last_msg.startswith("LOAD")):
split = self.last_msg.split(":")
self._retMsg(self.load(split[1]))
elif (self.last_msg == 'UNLOAD'):
self._retMsg(self.unload())
elif (self.last_msg == 'STATUS'):
self._retMsg(self.status, True)
else:
self._retMsg("Unknown Command")
else:
@ -405,7 +513,7 @@ if __name__ == "__main__":
# Do some testing
in_q.put("LOADED?")
in_q.put("PLAY")
in_q.put("LOAD:\\Users\\matth\\Documents\\GitHub\\bapsicle\\dev\\test.mp3")
in_q.put("LOAD:dev/test.mp3")
in_q.put("LOADED?")
in_q.put("PLAY")
print("Entering infinite loop.")

118
server.py
View file

@ -1,3 +1,18 @@
"""
BAPSicle Server
Next-gen audio playout server for University Radio York playout,
based on WebStudio interface.
Flask Server
Authors:
Matthew Stratford
Michael Grace
Date:
October, November 2020
"""
import multiprocessing
import player
from flask import Flask, render_template, send_from_directory, request
@ -7,6 +22,10 @@ import logging
from helpers.os_environment import isMacOS
from helpers.device_manager import DeviceManager
import pyttsx3
import config
from typing import Dict, List
setproctitle.setproctitle("BAPSicle - Server")
@ -36,6 +55,9 @@ channel_p = []
stopping = False
### General Endpoints
@app.errorhandler(404)
def page_not_found(e):
data = {
@ -84,6 +106,7 @@ def ui_status():
}
return render_template('status.html', data=data)
### Channel Audio Options
@app.route("/player/<int:channel>/play")
def play(channel):
@ -130,6 +153,27 @@ def output(channel, name):
channel_to_q[channel].put("OUTPUT:" + name)
return ui_status()
@app.route("/player/<int:channel>/autoadvance/<int:state>")
def autoadvance(channel: int, state: int):
channel_to_q[channel].put("AUTOADVANCE:" + str(state))
return ui_status()
@app.route("/player/<int:channel>/repeat/<state>")
def repeat(channel: int, state):
channel_to_q[channel].put("REPEAT:" + state.upper())
return ui_status()
@app.route("/player/<int:channel>/playonload/<int:state>")
def playonload(channel: int, state: int):
channel_to_q[channel].put("PLAYONLOAD:" + str(state))
return ui_status()
### Channel Items
@app.route("/player/<int:channel>/load/<int:timeslotitemid>")
def load(channel:int, timeslotitemid: int):
channel_to_q[channel].put("LOAD:" + str(timeslotitemid))
return ui_status()
@app.route("/player/<int:channel>/unload")
def unload(channel):
@ -138,6 +182,41 @@ def unload(channel):
return ui_status()
@app.route("/player/<int:channel>/add", methods=["POST"])
def add_to_plan(channel: int):
new_item: Dict[str, any] = {
"timeslotitemid": int(request.form["timeslotitemid"]),
"filename": request.form["filename"],
"title": request.form["title"],
"artist": request.form["artist"],
}
channel_to_q[channel].put("ADD:" + json.dumps(new_item))
return new_item
@app.route("/player/<int:channel>/move/<int:timeslotitemid>/<int:position>")
def move_plan(channel: int, timeslotitemid: int, position: int):
channel_to_q[channel].put("MOVE:" + json.dumps({"timeslotitemid": timeslotitemid, "position": position}))
#TODO Return
return True
@app.route("/player/<int:channel>/remove/<int:timeslotitemid>")
def remove_plan(channel: int, timeslotitemid: int):
channel_to_q[channel].put("REMOVE:" + timeslotitemid)
#TODO Return
return True
@app.route("/player/<int:channel>/clear")
def clear_channel_plan(channel: int):
channel_to_q[channel].put("CLEAR")
#TODO Return
return True
### General Channel Endpoints
@app.route("/player/<int:channel>/status")
def status(channel):
@ -166,7 +245,14 @@ def quit():
def all_stop():
for channel in channel_to_q:
channel.put("STOP")
ui_status()
return ui_status()
@app.route("/player/all/clear")
def clear_all_channels():
for channel in channel_to_q:
channel.put("CLEAR")
return ui_status()
@app.route('/static/<path:path>')
@ -190,9 +276,35 @@ def startServer():
)
channel_p[channel].start()
# Don't use reloader, it causes Nested Processes!
app.run(host='0.0.0.0', port=13500, debug=True, use_reloader=False)
# Welcome Speech
text_to_speach = pyttsx3.init()
text_to_speach.save_to_file(
"""Thank-you for installing BAPSicle - the play-out server from the broadcasting and presenting suite.
This server is accepting connections on port {0}
The version of the server service is {1}
Please refer to the documentation included with this application for further assistance.""".format(
config.PORT,
config.VERSION
),
"dev/welcome.mp3"
)
text_to_speach.runAndWait()
new_item: Dict[str, any] = {
"timeslotitemid": 0,
"filename": "dev/welcome.mp3",
"title": "Welcome to BAPSicle",
"artist": "University Radio York",
}
channel_to_q[0].put("ADD:" + json.dumps(new_item))
channel_to_q[0].put("LOAD:0")
channel_to_q[0].put("PLAY")
# Don't use reloader, it causes Nested Processes!
app.run(host='0.0.0.0', port=13500, debug=True, use_reloader=False)
def stopServer():
print("Stopping server.py")