diff --git a/config.py.example b/config.py.example index 9925970..446a2a4 100644 --- a/config.py.example +++ b/config.py.example @@ -1,2 +1,6 @@ # BAPSicle Details -VERSION: float = 1.0 \ No newline at end of file +VERSION: float = 1.0 + +# API Settings +API_KEY: str = "" +MYRADIO_BASE_URL: str = "https://ury.org.uk/myradio" diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py new file mode 100644 index 0000000..4957023 --- /dev/null +++ b/helpers/myradio_api.py @@ -0,0 +1,54 @@ +""" + BAPSicle Server + Next-gen audio playout server for University Radio York playout, + based on WebStudio interface. + + MyRadio API Handler + + In an ideal world, this module gives out and is fed PlanItems. + This means it can be swapped for a different backend in the (unlikely) event + someone else wants to integrate BAPsicle with something else. + + Authors: + Matthew Stratford + Michael Grace + + Date: + November 2020 +""" +import requests + +import config +from plan import PlanItem +from helpers.os_environment import resolve_external_file_path + + +class MyRadioAPI(): + + @classmethod + 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) + + elif item.managedId: + itemType = "managed" + id = item.managedId + url = "{}/NIPSWeb/managed_play?managedid={}&api_key={}".format(config.MYRADIO_BASE_URL, id, config.API_KEY) + + else: + return None + + request = requests.get(url, timeout=10) + + if request.status_code != 200: + return None + + filename: str = resolve_external_file_path("/music-tmp/{}-{}.{}".format(itemType, id, format)) + + with open(filename, 'wb') as file: + file.write(request.content) + + return filename diff --git a/helpers/state_manager.py b/helpers/state_manager.py index 8e1b28c..f4d2503 100644 --- a/helpers/state_manager.py +++ b/helpers/state_manager.py @@ -4,8 +4,9 @@ import logging import time from datetime import datetime from copy import copy +from typing import List -from plan import PlanObject +from plan import PlanItem from helpers.logging_manager import LoggingManager from helpers.os_environment import resolve_external_file_path @@ -45,11 +46,11 @@ class StateManager: try: file_state = json.loads(file_state) - # Turn from JSON -> PlanObject + # Turn from JSON -> PlanItem if "channel" in file_state: - file_state["loaded_item"] = PlanObject( + file_state["loaded_item"] = PlanItem( file_state["loaded_item"]) if file_state["loaded_item"] else None - file_state["show_plan"] = [PlanObject(obj) for obj in file_state["show_plan"]] + file_state["show_plan"] = [PlanItem(obj) for obj in file_state["show_plan"]] # Now feed the loaded state into the initialised state manager. self.state = file_state @@ -100,7 +101,7 @@ class StateManager: with open(self.filepath, "w") as file: file.write(state_json) - def update(self, key, value): + def update(self, key, value, index = -1): update_file = True if (key in self.__rate_limit_params_until.keys()): # The key we're trying to update is expected to be updating very often, @@ -112,12 +113,22 @@ class StateManager: state_to_update = self.state - if key in state_to_update and state_to_update[key] == value: + + if key in state_to_update and index == -1 and state_to_update[key] == value: # We're trying to update the state with the same value. # In this case, ignore the update return - state_to_update[key] = value + if index > -1 and key in state_to_update: + if not isinstance(state_to_update[key], list): + return + list_items = state_to_update[key] + if index >= len(list_items): + return + list_items[index] = value + state_to_update[key] = list_items + else: + state_to_update[key] = value self.state = state_to_update diff --git a/plan.py b/plan.py index f76b9e6..a49a02a 100644 --- a/plan.py +++ b/plan.py @@ -15,28 +15,44 @@ from typing import Dict import os -class PlanObject: - _timeslotitemid: int = 0 +class PlanItem: + _timeslotItemId: int = 0 _filename: str = "" _title: str = "" _artist: str = "" + _trackId: int = None + _managedId: int = None @property - def timeslotitemid(self) -> int: - return self._timeslotitemid + def timeslotItemId(self) -> int: + return self._timeslotItemId @property def filename(self) -> str: return self._filename + @filename.setter + def filename(self, value: str): + self._filename = value + @property def name(self) -> str: return "{0} - {1}".format(self._title, self._artist) if self._artist else self._title + @property + def trackId(self) -> int: + return self._trackId + + @property + def managedId(self) -> int: + return self._managedId + @property def __dict__(self) -> Dict[str, any]: return { - "timeslotitemid": self.timeslotitemid, + "timeslotItemId": self.timeslotItemId, + "trackId": self._trackId, + "managedId": self._managedId, "title": self._title, "artist": self._artist, "name": self.name, @@ -44,14 +60,16 @@ class PlanObject: } def __init__(self, new_item: Dict[str, any]): - self._timeslotitemid = new_item["timeslotitemid"] - self._filename = new_item["filename"] + self._timeslotItemId = new_item["timeslotItemId"] + self._trackId = new_item["trackId"] if "trackId" in new_item else None + self._managedId = new_item["managedId"] if "managedId" in new_item else None + self._filename = new_item["filename"] # This could be a temp dir for API-downloaded items, or a mapped drive. 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("/", '\\') - \ No newline at end of file + if self.filename: + if os.path.sep == "/": + self._filename = self.filename.replace("\\", '/') + else: + self._filename = self.filename.replace("/", '\\') diff --git a/player.py b/player.py index 54669d1..eefc601 100644 --- a/player.py +++ b/player.py @@ -29,7 +29,7 @@ import sys from typing import Callable, Dict, List -from plan import PlanObject +from plan import PlanItem # Stop the Pygame Hello message. import os @@ -37,6 +37,7 @@ os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide" from pygame import mixer from mutagen.mp3 import MP3 +from helpers.myradio_api import MyRadioAPI from helpers.os_environment import isMacOS from helpers.state_manager import StateManager from helpers.logging_manager import LoggingManager @@ -224,13 +225,13 @@ class Player(): ### 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)]) + self.state.update("show_plan", self.state.state["show_plan"] + [PlanItem(new_item)]) return True - def remove_from_plan(self, timeslotitemid: int) -> bool: + 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: + if plan_copy[i].timeslotItemId == timeslotItemId: plan_copy.remove(i) self.state.update("show_plan", plan_copy) return True @@ -240,31 +241,43 @@ class Player(): self.state.update("show_plan", []) return True - def load(self, timeslotitemid: int): + def load(self, timeslotItemId: int): if not self.isPlaying: self.unload() - updated: bool = False + found: 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 + showplan = self.state.state["show_plan"] + + loaded_item: PlanItem + + for i in range(len(showplan)): + if showplan[i].timeslotItemId == timeslotItemId: + loaded_item = showplan[i] + found = True break - if not updated: - print("Failed to find timeslotitemid:", timeslotitemid) + if not found: + self.logger.log.error("Failed to find timeslotItemId: {}".format(timeslotItemId)) return False - filename: str = self.state.state["loaded_item"].filename + if (loaded_item.filename == "" or loaded_item.filename == None): + loaded_item.filename = MyRadioAPI.get_filename(item = loaded_item) + self.state.update("loaded_item", loaded_item) + + for i in range(len(showplan)): + if showplan[i].timeslotItemId == timeslotItemId: + self.state.update("show_plan", index=i, value=loaded_item) + break + # TODO: Update the show plan filenames try: - self.logger.log.info("Loading file: " + str(filename)) - mixer.music.load(filename) + self.logger.log.info("Loading file: " + str(loaded_item.filename)) + mixer.music.load(loaded_item.filename) except: # We couldn't load that file. - self.logger.log.exception("Couldn't load file: " + str(filename)) + self.logger.log.exception("Couldn't load file: " + str(loaded_item.filename)) return False try: @@ -313,7 +326,7 @@ class Player(): loadedItem = self.state.state["loaded_item"] if (loadedItem): - self.load(loadedItem.timeslotitemid) + self.load(loadedItem.timeslotItemId) if wasPlaying: self.unpause() @@ -348,14 +361,14 @@ class Player(): # 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 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) + 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) + self.load(self.state.state["show_plan"][0].timeslotItemId) # Play on Load if self.state.state["play_on_load"]: @@ -400,8 +413,8 @@ class Player(): self.output() if loaded_state["loaded_item"]: - self.logger.log.info("Loading filename: " + loaded_state["loaded_item"].filename) - self.load(loaded_state["loaded_item"].timeslotitemid) + self.logger.log.info("Loading filename: " + str(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"])) diff --git a/server.py b/server.py index b108652..b3db115 100644 --- a/server.py +++ b/server.py @@ -15,7 +15,7 @@ import multiprocessing import player -from flask import Flask, render_template, send_from_directory, request +from flask import Flask, render_template, send_from_directory, request, jsonify import json import setproctitle import logging @@ -225,10 +225,9 @@ def playonload(channel: int, state: int): # Channel Items - -@app.route("/player//load/") -def load(channel: int, timeslotitemid: int): - channel_to_q[channel].put("LOAD:" + str(timeslotitemid)) +@app.route("/player//load/") +def load(channel:int, timeslotItemId: int): + channel_to_q[channel].put("LOAD:" + str(timeslotItemId)) return ui_status() @@ -243,7 +242,7 @@ def unload(channel): @app.route("/player//add", methods=["POST"]) def add_to_plan(channel: int): new_item: Dict[str, any] = { - "timeslotitemid": int(request.form["timeslotitemid"]), + "timeslotItemId": int(request.form["timeslotItemId"]), "filename": request.form["filename"], "title": request.form["title"], "artist": request.form["artist"], @@ -253,18 +252,16 @@ def add_to_plan(channel: int): return new_item - -@app.route("/player//move//") -def move_plan(channel: int, timeslotitemid: int, position: int): - channel_to_q[channel].put("MOVE:" + json.dumps({"timeslotitemid": timeslotitemid, "position": position})) +@app.route("/player//move//") +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//remove/") -def remove_plan(channel: int, timeslotitemid: int): - channel_to_q[channel].put("REMOVE:" + timeslotitemid) +@app.route("/player//remove/") +def remove_plan(channel: int, timeslotItemId: int): + channel_to_q[channel].put("REMOVE:" + timeslotItemId) # TODO Return return True @@ -281,8 +278,13 @@ def clear_channel_plan(channel: int): @app.route("/player//status") -def status(channel): +def channel_json(channel: int): + try: + return jsonify(status(channel)) + except: + return status(channel) +def status(channel): channel_to_q[channel].put("STATUS") while True: response = channel_from_q[channel].get() @@ -379,7 +381,7 @@ def startServer(): text_to_speach.runAndWait() new_item: Dict[str, any] = { - "timeslotitemid": 0, + "timeslotItemId": 0, "filename": "dev/welcome.mp3", "title": "Welcome to BAPSicle", "artist": "University Radio York",