Merge pull request #10 from UniversityRadioYork/myradio-music
Play music and managed items from MyRadio
This commit is contained in:
commit
fc3e5db08c
6 changed files with 160 additions and 58 deletions
|
@ -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
54
helpers/myradio_api.py
Normal 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
|
|
@ -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
42
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("/", '\\')
|
||||
|
||||
if self.filename:
|
||||
if os.path.sep == "/":
|
||||
self._filename = self.filename.replace("\\", '/')
|
||||
else:
|
||||
self._filename = self.filename.replace("/", '\\')
|
||||
|
|
57
player.py
57
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"]))
|
||||
|
|
34
server.py
34
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/<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",
|
||||
|
|
Loading…
Reference in a new issue