From 1a59b2d5bfa44fcbed4ab03c07a68c4c47b4c866 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sun, 14 Feb 2021 00:29:47 +0000 Subject: [PATCH] Add show plan loading, send more info to Webstudio UI. --- helpers/myradio_api.py | 83 +++++++++++++++++++++--- player.py | 139 ++++++++++++++++++++++++++++------------- server.py | 35 +++++++++-- websocket_server.py | 24 ++++--- 4 files changed, 214 insertions(+), 67 deletions(-) diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py index d7f2f08..2b8aefc 100644 --- a/helpers/myradio_api.py +++ b/helpers/myradio_api.py @@ -17,34 +17,77 @@ November 2020 """ import requests - +import json import config from plan import PlanItem from helpers.os_environment import resolve_external_file_path - - +from helpers.logging_manager import LoggingManager +from logging import CRITICAL, INFO, DEBUG class MyRadioAPI(): + logger = None - @classmethod - def get_filename(cls, item: PlanItem): + def __init__(self, logger: LoggingManager): + self.logger = logger + + def get_non_api_call(self, url): + + url = "{}{}".format(config.MYRADIO_BASE_URL, url) + + if "?" in url: + url += "&api_key={}".format(config.API_KEY) + else: + url += "?api_key={}".format(config.API_KEY) + + self._log("Requesting non-API URL: " + url) + request = requests.get(url, timeout=10) + self._log("Finished request.") + + if request.status_code != 200: + self._logException("Failed to get API request. Status code: " + str(request.status_code)) + self._logException(str(request.content)) + return None + + return request + + def get_apiv2_call(self, url): + + url = "{}/v2{}".format(config.MYRADIO_API_URL, url) + + if "?" in url: + url += "&api_key={}".format(config.API_KEY) + else: + url += "?api_key={}".format(config.API_KEY) + + self._log("Requesting API V2 URL: " + url) + request = requests.get(url, timeout=10) + self._log("Finished request.") + + if request.status_code != 200: + self._logException("Failed to get API request. Status code: " + str(request.status_code)) + self._logException(str(request.content)) + return None + + return request + + def get_filename(self, item: PlanItem): format = "mp3" # TODO: Maybe we want this customisable? if item.trackId: itemType = "track" id = item.trackId - url = "{}/NIPSWeb/secure_play?trackid={}&{}&api_key={}".format(config.MYRADIO_BASE_URL, id, format, config.API_KEY) + url = "/NIPSWeb/secure_play?trackid={}&{}".format(id, format) elif item.managedId: itemType = "managed" id = item.managedId - url = "{}/NIPSWeb/managed_play?managedid={}&api_key={}".format(config.MYRADIO_BASE_URL, id, config.API_KEY) + url = "/NIPSWeb/managed_play?managedid={}".format(id) else: return None - request = requests.get(url, timeout=10) - if request.status_code != 200: - # TODO: Log something here + request = self.get_non_api_call(url) + + if not request: return None filename: str = resolve_external_file_path("/music-tmp/{}-{}.{}".format(itemType, id, format)) @@ -53,3 +96,23 @@ class MyRadioAPI(): file.write(request.content) return filename + + def get_showplan(self, timeslotid: int): + + url = "/timeslot/{}/showplan".format(timeslotid) + request = self.get_apiv2_call(url) + + if not request: + self._logException("Failed to get show plan.") + return None + + return json.loads(request.content)["payload"] + + + + def _log(self, text:str, level: int = INFO): + self.logger.log.log(level, "MyRadio API: " + text) + + def _logException(self, text:str): + self.logger.log.exception("MyRadio API: " + text) + diff --git a/player.py b/player.py index 112d9b9..8f9b153 100644 --- a/player.py +++ b/player.py @@ -35,7 +35,7 @@ from plan import PlanItem # Stop the Pygame Hello message. import os os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" -from pygame import mixer, NOEVENT, USEREVENT, event +from pygame import mixer, NOEVENT, USEREVENT, event, init from mutagen.mp3 import MP3 from helpers.myradio_api import MyRadioAPI @@ -49,7 +49,9 @@ class Player(): running = False out_q = None last_msg = "" + last_time_update = None logger = None + api = None __default_state = { "initialised": False, @@ -225,6 +227,29 @@ class Player(): return False # 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"] + if len(plan) > channel: + for plan_item in plan[str(channel)]: + try: + new_item: Dict[str, any] = { + "channelWeight": int(plan_item["weight"]), + "filename": None, + "title": plan_item["title"], + "artist": plan_item["artist"] if "artist" in plan_item.keys() else None, + "timeslotItemId": int(plan_item["timeslotitemid"]) if "timeslotitemid" in plan_item.keys() and plan_item["timeslotitemid"] != None else None, + "trackId": int(plan_item["trackid"]) if "managedid" not in plan_item.keys() and plan_item["trackid"] != None else None, + "recordId": int(plan_item["trackid"]) if "trackid" in plan_item.keys() and plan_item["trackid"] != None else None, # TODO This is wrong. + "managedId": int(plan_item["managedid"]) if "managedid" in plan_item.keys() and plan_item["managedid"] != None else None, + } + self.add_to_plan(new_item) + except: + continue + + return True + def add_to_plan(self, new_item: Dict[str, Any]) -> bool: self.state.update("show_plan", self.state.state["show_plan"] + [PlanItem(new_item)]) @@ -261,7 +286,7 @@ class Player(): return False if (loaded_item.filename == "" or loaded_item.filename == None): - loaded_item.filename = MyRadioAPI.get_filename(item = loaded_item) + loaded_item.filename = self.api.get_filename(item = loaded_item) if not loaded_item.filename: return False @@ -334,6 +359,39 @@ class Player(): return True + 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. + if loaded_item == None or not self.isPlaying: + return + + if self.out_q: + self.out_q.put("STOPPED") # Tell clients that we've stopped playing. + + # Track has ended + print("Finished", 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].channelWeight == loaded_item.channelWeight: + if len(self.state.state["show_plan"]) > i+1: + self.load(self.state.state["show_plan"][i+1].channelWeight) + break + + # Repeat All + elif self.state.state["repeat"] == "ALL": + self.load(self.state.state["show_plan"][0].channelWeight) + + # Play on Load + if self.state.state["play_on_load"]: + self.play() + def _updateState(self, pos: Optional[float] = None): self.state.update("initialised", self.isInit) @@ -350,32 +408,11 @@ class Player(): self.state.update("remaining", self.state.state["length"] - self.state.state["pos_true"]) - loaded_item = self.state.state["loaded_item"] - if loaded_item == None or self.state.state["remaining"] != 0: - return + def _ping_times(self): + if self.last_time_update == None or self.last_time_update + 1 < time.time(): + self.last_time_update = time.time() + self.out_q.put("POS:" + str(int(self.state.state["pos_true"]))) - # Track has ended - print("Finished", 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].channelWeight == loaded_item.channelWeight: - if len(self.state.state["show_plan"]) > i+1: - self.load(self.state.state["show_plan"][i+1].channelWeight) - break - - # Repeat All - elif self.state.state["repeat"] == "ALL": - self.load(self.state.state["show_plan"][0].channelWeight) - - # Play on Load - if self.state.state["play_on_load"]: - self.play() def _retMsg(self, msg: Any, okay_str: Any = False): @@ -389,7 +426,9 @@ class Player(): response += "FAIL:" + msg else: response += "FAIL" + self.logger.log.info(("Preparing to send: {}".format(response))) if self.out_q: + self.logger.log.info(("Sending: {}".format(response))) self.out_q.put(response) def __init__(self, channel: int, in_q: multiprocessing.Queue, out_q: multiprocessing.Queue): @@ -398,11 +437,16 @@ class Player(): setproctitle.setproctitle(process_title) multiprocessing.current_process().name = process_title + # Init pygame, only used really for the end of playback trigger. + init() + self.running = True self.out_q = out_q self.logger = LoggingManager("channel" + str(channel)) + self.api = MyRadioAPI(self.logger) + self.state = StateManager("channel" + str(channel), self.logger, self.__default_state, self.__rate_limited_params) self.state.update("channel", channel) @@ -434,6 +478,7 @@ class Player(): while self.running: time.sleep(0.1) self._updateState() + self._ping_times() try: try: self.last_msg = in_q.get_nowait() @@ -453,7 +498,7 @@ class Player(): elif self.isInit: message_types: Dict[str, Callable[..., Any]] = { # TODO Check Types - "STATUS": lambda: self._retMsg(self.status, True), + "STATUS": lambda: self._retMsg(self.status, True), # Audio Playout "PLAY": lambda: self._retMsg(self.play()), @@ -466,6 +511,8 @@ class Player(): "PLAYONLOAD": lambda: self._retMsg(self.set_play_on_load(int(self.last_msg.split(":")[1]))), # Show Plan Items + "GET_PLAN": lambda: self._retMsg(self.get_plan(int(self.last_msg.split(":")[1]))), + "LOAD": lambda: self._retMsg(self.load(int(self.last_msg.split(":")[1]))), "LOADED?": lambda: self._retMsg(self.isLoaded), "UNLOAD": lambda: self._retMsg(self.unload()), @@ -479,6 +526,12 @@ class Player(): if message_type in message_types.keys(): message_types[message_type]() + if message_type != "STATUS": + ## Then a super hacky hack. Send the status again to update Webstudio + self._updateState() + self.last_msg = "STATUS" + self._retMsg(self.status, True) + elif (self.last_msg == 'QUIT'): self.running = False continue @@ -486,23 +539,25 @@ class Player(): else: self._retMsg("Unknown Command") else: + if (self.last_msg == 'STATUS'): self._retMsg(self.status) else: self._retMsg(False) - #try: - #callback_event = event.poll() - #print(callback_event) - #if callback_event.type == PLAYBACK_END: - # if self.out_q: - # print("Playback endded at end of Track.") - # self.out_q.put("STOP") # Tell clients that we've stopped playing. - #elif callback_event.type == NOEVENT: - # pass - #print("Another message") - #except: - # pass + + + 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") @@ -510,8 +565,8 @@ class Player(): except SystemExit: self.logger.log.info("Received SystemExit") break - except: - self.logger.log.exception("Received unexpected exception.") + except Exception as e: + self.logger.log.exception("Received unexpected exception: {}".format(e)) break self.logger.log.info("Quiting player ", channel) diff --git a/server.py b/server.py index 2f36a87..d948db7 100644 --- a/server.py +++ b/server.py @@ -24,6 +24,7 @@ from typing import Any, Optional import json import setproctitle import logging + from helpers.os_environment import isMacOS from helpers.device_manager import DeviceManager @@ -48,6 +49,8 @@ default_state = { "num_channels": 3 } +logger = None +state = None class BAPSicleServer(): @@ -57,6 +60,13 @@ class BAPSicleServer(): setproctitle.setproctitle(process_title) multiprocessing.current_process().name = process_title + global logger + global state + logger = LoggingManager("BAPSicleServer") + + state = StateManager("BAPSicleServer", logger, default_state) + state.update("server_version", config.VERSION) + asyncio.get_event_loop().run_until_complete(startServer()) asyncio.get_event_loop().run_forever() @@ -69,16 +79,21 @@ class PlayerHandler(): for channel in range(len(channel_from_q)): try: message = channel_from_q[channel].get_nowait() - websocket_to_q[channel].put(message) - ui_to_q[channel].put(message) + print("Player Handler saw:", message.split(":")[0]) + try: + websocket_to_q[channel].put_nowait(message) + except Exception as e: + print(e) + pass + try: + ui_to_q[channel].put_nowait(message) + except Exception as e: + print(e) + pass except: pass time.sleep(0.01) -logger = LoggingManager("BAPSicleServer") - -state = StateManager("BAPSicleServer", logger, default_state) -state.update("server_version", config.VERSION) app = Flask(__name__, static_url_path='') @@ -307,6 +322,14 @@ def channel_json(channel: int): except: return status(channel) +@app.route("/plan/load/") +def load_showplan(timeslotid: int): + + for channel in channel_to_q: + channel.put("GET_PLAN:" + str(timeslotid)) + + return ui_status() + def status(channel: int): channel_to_q[channel].put("STATUS") i = 0 diff --git a/websocket_server.py b/websocket_server.py index 20156de..ce1a9c2 100644 --- a/websocket_server.py +++ b/websocket_server.py @@ -24,6 +24,7 @@ async def websocket_handler(websocket, path): async for message in websocket: data = json.loads(message) channel = int(data["channel"]) + print(data) if "command" in data.keys(): if data["command"] == "PLAY": channel_to_q[channel].put("PLAY") @@ -38,7 +39,6 @@ async def websocket_handler(websocket, path): elif data["command"] == "LOAD": channel_to_q[channel].put("LOAD:" + str(data["weight"])) elif data["command"] == "ADD": - print(data) if "managedId" in data["newItem"].keys() and isinstance(data["newItem"]["managedId"], str): if data["newItem"]["managedId"].startswith("managed"): managed_id = int(data["newItem"]["managedId"].split(":")[1]) @@ -76,15 +76,21 @@ async def websocket_handler(websocket, path): for channel in range(len(webstudio_to_q)): try: message = webstudio_to_q[channel].get_nowait() - if not message.startswith("STATUS"): - continue # Ignore non state updates for now. - try: - message = message.split("OKAY:")[1] - message = json.loads(message) - except: - pass + command = message.split(":")[0] + print("Websocket Out:", command) + if command == "STATUS": + try: + message = message.split("OKAY:")[1] + message = json.loads(message) + except: + continue + elif command == "POS": + message = message.split(":")[1] + else: + continue + data = json.dumps({ - "command": "STATUS", + "command": command, "data": message, "channel": channel })