Merge pull request #10 from UniversityRadioYork/myradio-music

Play music and managed items from MyRadio
This commit is contained in:
Matthew Stratford 2020-12-08 19:44:56 +00:00 committed by GitHub
commit fc3e5db08c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 160 additions and 58 deletions

View file

@ -1,2 +1,6 @@
# BAPSicle Details
VERSION: float = 1.0
VERSION: float = 1.0
# API Settings
API_KEY: str = ""
MYRADIO_BASE_URL: str = "https://ury.org.uk/myradio"

54
helpers/myradio_api.py Normal file
View file

@ -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

View file

@ -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

42
plan.py
View file

@ -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("/", '\\')
if self.filename:
if os.path.sep == "/":
self._filename = self.filename.replace("\\", '/')
else:
self._filename = self.filename.replace("/", '\\')

View file

@ -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"]))

View file

@ -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/<int:channel>/load/<int:timeslotitemid>")
def load(channel: int, timeslotitemid: int):
channel_to_q[channel].put("LOAD:" + str(timeslotitemid))
@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()
@ -243,7 +242,7 @@ def unload(channel):
@app.route("/player/<int:channel>/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/<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}))
@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)
@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
@ -281,8 +278,13 @@ def clear_channel_plan(channel: int):
@app.route("/player/<int:channel>/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",