Merge branch 'dev' into mstratford/quitting

Likely to break something, but here goes.
This commit is contained in:
Matthew Stratford 2022-03-11 22:09:45 +00:00
commit 92a6b86ccd
31 changed files with 761 additions and 111 deletions

View file

@ -9,7 +9,8 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.9]
python-version: ['3.9']
node-version: ['14']
steps:
- uses: actions/checkout@v2
@ -17,6 +18,11 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/checkout@v2
- name: Set up Node ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Build .app
run: |
npm run presenter-make
@ -40,7 +46,8 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.9]
python-version: ['3.9']
node-version: ['14']
steps:
- uses: actions/checkout@v2
@ -48,6 +55,11 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/checkout@v2
- name: Set up Node ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Build executable
run: |
npm run presenter-make
@ -70,7 +82,8 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: [3.9]
python-version: ['3.9']
node-version: ['14']
steps:
- uses: actions/checkout@v2
@ -78,6 +91,11 @@ jobs:
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: actions/checkout@v2
- name: Set up Node ${{ matrix.node-version }}
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
- name: Build .exe
run: |
npm run presenter-make

2
.gitignore vendored
View file

@ -26,4 +26,4 @@ music-tmp/
presenter-build
node_modules
node_modules/

20
alerts/dummy.py Normal file
View file

@ -0,0 +1,20 @@
from helpers.alert_manager import AlertProvider
from package import BETA
from baps_types.alert import WARNING, Alert
# Dummy alert provider for testing basics like UI without needing to actually cause errors.
class DummyAlertProvider(AlertProvider):
def get_alerts(self):
if BETA:
return [Alert(
{
"start_time": -1,
"id": "test",
"title": "BAPSicle is in Debug Mode",
"description": "This is a test alert. It will not appear on production builds.",
"module": "Test",
"severity": WARNING
}
)]

99
alerts/player.py Normal file
View file

@ -0,0 +1,99 @@
# Any alerts produced by the player.py instances.
import json
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta
from helpers.os_environment import resolve_external_file_path
from helpers.alert_manager import AlertProvider
from baps_types.alert import CRITICAL, WARNING, Alert
from baps_types.happytime import happytime
MODULE = "Player" # This should match the log file, so the UI will link to the logs page.
class PlayerAlertProvider(AlertProvider):
_server_state: Dict[str, Any]
_states: List[Optional[Dict[str, Any]]] = []
_player_count: int
def __init__(self):
# Player count only changes after server restart, may as well just load this once.
with open(resolve_external_file_path("state/BAPSicleServer.json")) as file:
self._server_state = json.loads(file.read())
self._player_count = int(self._server_state["num_channels"])
self._states = [None] * self._player_count
# To simplify monitoring (and allow detection of things going super
# weird), we are going to read from the state file to work out the alerts.
def get_alerts(self):
for channel in range(self._player_count):
with open(resolve_external_file_path("state/Player{}.json".format(channel))) as file:
self._states[channel] = json.loads(file.read())
funcs = [self._channel_count, self._initialised, self._start_time]
alerts: List[Alert] = []
for func in funcs:
func_alerts = func()
if func_alerts:
alerts.extend(func_alerts)
return alerts
def _channel_count(self):
if self._player_count <= 0:
return [Alert({
"start_time": -1, # Now
"id": "no_channels",
"title": "There are no players configured.",
"description": "The number of channels configured is {}. \
Please set to at least 1 on the 'Server Config' page."
.format(self._player_count),
"module": MODULE+"Handler",
"severity": CRITICAL
})]
def _initialised(self):
alerts: List[Alert] = []
for channel in range(self._player_count):
if self._states[channel] and not self._states[channel]["initialised"]:
alerts.append(Alert({
"start_time": -1, # Now
"id": "player_{}_not_initialised".format(channel),
"title": "Player {} is not initialised.".format(channel),
"description": "This typically means the player channel was not able find the configured sound output \
on the system. Please check the 'Player Config' and Player logs to determine the cause.",
"module": MODULE+str(channel),
"severity": CRITICAL
}))
return alerts
def _start_time(self):
server_start_time = self._server_state["start_time"]
server_start_time = datetime.fromtimestamp(server_start_time)
delta = timedelta(
seconds=30,
)
alerts: List[Alert] = []
for channel in range(self._player_count):
start_time = self._states[channel]["start_time"]
start_time = datetime.fromtimestamp(start_time)
if (start_time > server_start_time + delta):
alerts.append(Alert({
"start_time": -1,
"id": "player_{}_restarted".format(channel),
"title": "Player {} restarted after the server started.".format(channel),
"description":
"""Player {} last restarted at {}, after the server first started at {}, suggesting a failure.
This likely means there was an unhandled exception in the player code, causing the server to restart the player.
Please check player logs to investigate the cause. Please restart the server to clear this warning."""
.format(channel, happytime(start_time), happytime(server_start_time)),
"module": MODULE+str(channel),
"severity": WARNING
}))
return alerts

77
alerts/server.py Normal file
View file

@ -0,0 +1,77 @@
# Any alerts produced by the server.py layer. This likely means BIG issues.
import json
from typing import Any, Dict, List
from datetime import datetime, timedelta
from helpers.os_environment import resolve_external_file_path
from helpers.alert_manager import AlertProvider
from baps_types.alert import CRITICAL, WARNING, Alert
from baps_types.happytime import happytime
MODULE = "BAPSicleServer" # This should match the log file, so the UI will link to the logs page.
class ServerAlertProvider(AlertProvider):
_state: Dict[str, Any]
# To simplify monitoring (and allow detection of things going super
# weird), we are going to read from the state file to work out the alerts.
def get_alerts(self):
with open(resolve_external_file_path("state/BAPSicleServer.json")) as file:
self._state = json.loads(file.read())
funcs = [self._api_key, self._start_time]
alerts: List[Alert] = []
for func in funcs:
func_alerts = func()
if func_alerts:
alerts.extend(func_alerts)
return alerts
def _api_key(self):
if not self._state["myradio_api_key"]:
return [Alert({
"start_time": -1, # Now
"id": "api_key_missing",
"title": "MyRadio API Key is not configured.",
"description": "This means you will be unable to load show plans, audio items, or tracklist. \
Please set one on the 'Server Config' page.",
"module": MODULE,
"severity": CRITICAL
})]
if len(self._state["myradio_api_key"]) < 10:
return [Alert({
"start_time": -1,
"id": "api_key_missing",
"title": "MyRadio API Key seems incorrect.",
"description": "The API key is less than 10 characters, it's probably not a valid one. \
If it is valid, it shouldn't be.",
"module": MODULE,
"severity": WARNING
})]
def _start_time(self):
start_time = self._state["start_time"]
start_time = datetime.fromtimestamp(start_time)
delta = timedelta(
days=1,
)
if (start_time + delta > datetime.now()):
return [Alert({
"start_time": -1,
"id": "server_restarted",
"title": "BAPSicle restarted recently.",
"description":
"""The BAPSicle server restarted at {}, less than a day ago.
It may have been automatically restarted by the OS.
If this is not expected, please check logs to investigate why BAPSicle restarted/crashed."""
.format(happytime(start_time)),
"module": MODULE,
"severity": WARNING
})]

69
baps_types/alert.py Normal file
View file

@ -0,0 +1,69 @@
from typing import Any, Dict, Optional
from datetime import datetime
CRITICAL = "Critical"
WARNING = "Warning"
class Alert:
start_time: datetime
last_time: datetime
end_time: Optional[datetime]
id: str
title: str
description: str
module: str
severity: str
@property
def ui_class(self) -> str:
if self.severity == CRITICAL:
return "danger"
if self.severity == WARNING:
return "warning"
return "info"
# This alert has happened again.
def reoccured(self):
self.last_time = datetime.now()
self.end_time = None
# This alert has finished, just update end time and keep last_time.
def cleared(self):
self.end_time = datetime.now()
@property
def __dict__(self):
attrs = ["start_time", "last_time", "end_time", "id", "title", "description", "module", "severity"]
out = {}
for attr in attrs:
out[attr] = self.__getattribute__(attr)
return out
def __init__(self, new_data: Dict[str, Any]):
required_vars = [
"start_time", # Just in case an alert wants to show starting earlier than it is reported.
"id",
"title",
"description",
"module",
"severity"
]
for key in required_vars:
if key not in new_data.keys():
raise KeyError("Key {} is missing from data to create Alert.".format(key))
# if type(new_data[key]) != type(getattr(self,key)):
# raise TypeError("Key {} has type {}, was expecting {}."
# .format(key, type(new_data[key]), type(getattr(self,key))))
# Account for if the creator didn't want to set a custom time.
if key == "start_time" and new_data[key] == -1:
new_data[key] = datetime.now()
setattr(self, key, new_data[key])
self.last_time = self.start_time
self.end_time = None

5
baps_types/happytime.py Normal file
View file

@ -0,0 +1,5 @@
from datetime import datetime
def happytime(date: datetime):
return date.strftime("%Y-%m-%d %H:%M:%S")

View file

@ -7,7 +7,8 @@ build_branch="$(git branch --show-current)"
echo "BUILD: str = \"$build_commit\"" > ../build.py
echo "BRANCH: str = \"$build_branch\"" >> ../build.py
apt install libportaudio2
sudo apt install libportaudio2
sudo apt install python3-pip python3-venv ffmpeg
python3 -m venv ../venv
source ../venv/bin/activate
@ -19,6 +20,8 @@ pip3 install -e ../
python3 ./generate-build-exe-config.py
chmod +x output/BAPSicle
python3 ./build-exe.py
bash ./build-exe-pyinstaller-command.sh

View file

@ -1,2 +1,2 @@
#!/bin/bash
sudo apt-get -y install libasound-dev libportaudio2
sudo apt-get -y install libasound-dev libportaudio2 ffmpeg

View file

@ -1,14 +1,14 @@
wheel
pygame==2.0.1
sanic==21.3.4
sanic-Cors==1.0.0
pygame==2.0.2
sanic==21.9.3
sanic-Cors==1.0.1
syncer==1.3.0
aiohttp==3.7.4.post0
mutagen==1.45.1
sounddevice==0.4.2
setproctitle==1.2.2
pyttsx3==2.90
websockets==8.1
websockets==10.1
typing_extensions==3.10.0.0
pyserial==3.5
requests==2.26.0

View file

@ -0,0 +1,24 @@
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
import pygame._sdl2 as sdl2
import pygame
from pygame import mixer
pygame.init()
import time
mixer.init(44100, -16, 2, 1024)
is_capture = 0 # zero to request playback devices, non-zero to request recording devices
num = sdl2.get_num_audio_devices(is_capture)
names = [str(sdl2.get_audio_device_name(i, is_capture), encoding="utf-8") for i in range(num)]
mixer.quit()
for i in names:
print(i)
mixer.init(44100, -16, 2, 1024, devicename=i)
print(mixer.get_init())
mixer.music.load("/home/mstratford/Downloads/managed_play.mp3")
mixer.music.play()
# my_song = mixer.Sound("/home/mstratford/Downloads/managed_play.mp3")
# my_song.play()
time.sleep(5)
pygame.quit()

View file

@ -29,6 +29,11 @@ class FileManager:
current_process().name = process_title
terminator = Terminator()
self.normalisation_mode = server_config.get()["normalisation_mode"]
if self.normalisation_mode != "on":
self.logger.log.info("Normalisation is disabled.")
else:
self.logger.log.info("Normalisation is enabled.")
self.channel_count = server_config.get()["num_channels"]
self.channel_received = None
self.last_known_show_plan = [[]] * self.channel_count
@ -215,6 +220,10 @@ class FileManager:
# If we've preloaded everything, get to work normalising tracks before playback.
def do_normalise(self):
if self.normalisation_mode != "on":
return False
# Some channels still have files to preload, do nothing.
if self.known_channels_preloaded != [True] * self.channel_count:
return False # Didn't normalise

97
helpers/alert_manager.py Normal file
View file

@ -0,0 +1,97 @@
from typing import Any, List, Optional
# Magic for importing alert providers from alerts directory.
from pkgutil import iter_modules
from importlib import import_module
from inspect import getmembers, isclass
from sys import modules
from baps_types.alert import Alert
import alerts
def iter_namespace(ns_pkg):
# Specifying the second argument (prefix) to iter_modules makes the
# returned name an absolute name instead of a relative one. This allows
# import_module to work without having to do additional modification to
# the name.
return iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".")
class AlertProvider():
def __init__(self):
return None
def get_alerts(self):
return []
class AlertManager():
_alerts: List[Alert]
_providers: List[AlertProvider] = []
def __init__(self):
self._alerts = []
# Find all the alert providers from the /alerts/ directory.
providers = {
name: import_module(name)
for _, name, _
in iter_namespace(alerts)
}
for provider in providers:
classes: List[Any] = [
mem[1] for mem in getmembers(
modules[provider],
isclass) if mem[1].__module__ == modules[provider].__name__]
if (len(classes) != 1):
print(classes)
raise Exception("Can't import plugin " + provider + " because it doesn't have 1 class.")
self._providers.append(classes[0]())
print("Discovered alert providers: ", self._providers)
def poll_alerts(self):
# Poll modules for any alerts.
new_alerts: List[Optional[Alert]] = []
for provider in self._providers:
provider_alerts = provider.get_alerts()
if provider_alerts:
new_alerts.extend(provider_alerts)
# Here we replace new firing alerts with older ones, to keep any context.
# (This doesn't do much yet really, just remembering the start_time)
for existing in self._alerts:
found = False
for new in new_alerts:
# given we're removing alerts, got to skip any we removed.
if not new:
continue
if existing.id == new.id:
# Alert is continuing. Replace it with the old one.
index = new_alerts.index(new)
existing.reoccured()
new_alerts[index] = None # We're going to merge the existing and new, so clear the new one out.
found = True
break
if not found:
# The existing alert is gone, mark it as ended.
existing.cleared()
self._alerts.extend([value for value in new_alerts if value]) # Remove any nulled out new alerts
@property
def alerts_current(self):
self.poll_alerts()
return [alert for alert in self._alerts if not alert.end_time]
@property
def alerts_previous(self):
self.poll_alerts()
return [alert for alert in self._alerts if alert.end_time]

View file

@ -1,6 +1,13 @@
from typing import Any, Dict, List, Optional, Tuple
import sounddevice as sd
from helpers.os_environment import isLinux, isMacOS, isWindows
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
if isLinux():
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
import pygame._sdl2 as sdl2
from pygame import mixer
import glob
if isWindows():
@ -20,10 +27,10 @@ class DeviceManager:
return host_api
@classmethod
def _getAudioDevices(cls) -> sd.DeviceList:
def _getSDAudioDevices(cls):
# To update the list of devices
# Sadly this doesn't work on MacOS.
if not isMacOS():
# Sadly this only works on Windows. Linux hangs, MacOS crashes.
if isWindows():
sd._terminate()
sd._initialize()
devices: sd.DeviceList = sd.query_devices()
@ -31,11 +38,13 @@ class DeviceManager:
@classmethod
def getAudioOutputs(cls) -> Tuple[List[Dict]]:
host_apis = sd.query_hostapis()
devices: sd.DeviceList = cls._getAudioDevices()
host_apis = list(sd.query_hostapis())
devices: sd.DeviceList = cls._getSDAudioDevices()
for host_api_id in range(len(host_apis)):
if isWindows() and host_apis[host_api_id]["name"] not in WINDOWS_APIS:
# Linux SDL uses PortAudio, which SoundDevice doesn't find. So mark all as unsable.
if (isWindows() and host_apis[host_api_id]["name"] not in WINDOWS_APIS) or (isLinux()):
host_apis[host_api_id]["usable"] = False
else:
host_apis[host_api_id]["usable"] = True
@ -51,6 +60,15 @@ class DeviceManager:
return host_apis
@classmethod
def getAudioDevices(cls) -> List[str]:
mixer.init(44100, -16, 2, 1024)
is_capture = 0 # zero to request playback devices, non-zero to request recording devices
num = sdl2.get_num_audio_devices(is_capture)
names = [str(sdl2.get_audio_device_name(i, is_capture), encoding="utf-8") for i in range(num)]
mixer.quit()
return names
@classmethod
def getSerialPorts(cls) -> List[Optional[str]]:
"""Lists serial port names

View file

@ -102,16 +102,21 @@ class MyRadioAPI:
self._log("Requesting API V2 URL with method {}: {}".format(method, url))
request = None
if method == "GET":
request = await self.async_call(url, method="GET", timeout=timeout)
elif method == "POST":
self._log("POST data: {}".format(data))
request = await self.async_call(url, data=data, method="POST", timeout=timeout)
elif method == "PUT":
request = await self.async_call(url, method="PUT", timeout=timeout)
else:
self._logException("Invalid API method. Request not sent.")
try:
if method == "GET":
request = await self.async_call(url, method="GET", timeout=timeout)
elif method == "POST":
self._log("POST data: {}".format(data))
request = await self.async_call(url, data=data, method="POST", timeout=timeout)
elif method == "PUT":
request = await self.async_call(url, method="PUT", timeout=timeout)
else:
self._logException("Invalid API method. Request not sent.")
return None
except aiohttp.ClientError:
self._logException("Failed async API request.")
return None
self._log("Finished request.")
return request
@ -271,6 +276,8 @@ class MyRadioAPI:
request = await self.async_api_call(url, api_version="non")
if not request or not isinstance(request, (bytes, bytearray)):
# Remove the .downloading temp file given we gave up trying to download.
os.remove(filename + dl_suffix)
return (None, False) if did_download else None
try:
@ -291,7 +298,7 @@ class MyRadioAPI:
if not request or not isinstance(request, bytes):
self._logException("Failed to retrieve music playlists.")
return None
return []
return json.loads(request)["payload"]
@ -302,7 +309,7 @@ class MyRadioAPI:
if not request or not isinstance(request, bytes):
self._logException("Failed to retrieve music playlists.")
return None
return []
return json.loads(request)["payload"]
@ -319,7 +326,7 @@ class MyRadioAPI:
self._logException(
"Failed to retrieve items for aux playlist {}.".format(library_id)
)
return None
return []
return json.loads(request)["payload"]
@ -333,7 +340,7 @@ class MyRadioAPI:
self._logException(
"Failed to retrieve items for music playlist {}.".format(library_id)
)
return None
return []
return json.loads(request)["payload"]
@ -347,7 +354,7 @@ class MyRadioAPI:
if not request or not isinstance(request, bytes):
self._logException("Failed to search for track.")
return None
return []
return json.loads(request)["payload"]

12
package-lock.json generated
View file

@ -1,5 +1,13 @@
{
"name": "bapsicle",
"version": "3.0.0",
"lockfileVersion": 1
"version": "3.1.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
"yarn": {
"version": "1.22.15",
"resolved": "https://registry.npmjs.org/yarn/-/yarn-1.22.15.tgz",
"integrity": "sha512-AzoEDxj256BOS/jqDXA3pjyhmi4FRBBUMgYoTHI4EIt2EhREkvH0soPVEtnD+DQIJfU5R9bKhcZ1H9l8zPWeoA=="
}
}
}

View file

@ -24,5 +24,8 @@
"bugs": {
"url": "https://github.com/universityradioyork/bapsicle/issues"
},
"homepage": "https://github.com/universityradioyork/bapsicle#readme"
"homepage": "https://github.com/universityradioyork/bapsicle#readme",
"dependencies": {
"yarn": "^1.22.15"
}
}

103
player.py
View file

@ -21,8 +21,11 @@
# Stop the Pygame Hello message.
import os
os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide"
from helpers.os_environment import isLinux
# It's the only one we could get to work.
if isLinux():
os.putenv('SDL_AUDIODRIVER', 'pulseaudio')
from queue import Empty
import multiprocessing
@ -31,10 +34,11 @@ import copy
import json
import time
from typing import Any, Callable, Dict, List, Optional
from pygame import mixer
from pygame import mixer, error
from mutagen.mp3 import MP3
from syncer import sync
from threading import Timer
from datetime import datetime
from helpers.normalisation import get_normalised_filename_if_available, get_original_filename_from_normalised
from helpers.myradio_api import MyRadioAPI
@ -111,43 +115,45 @@ class Player:
@property
def isLoaded(self):
return self._isLoaded()
return self.state.get()["loaded"]
def _isLoaded(self, short_test: bool = False):
if not self.state.get()["loaded_item"]:
return False
if self.isPlaying:
return True
def _checkIsLoaded(self, short_test: bool = False):
# If we don't want to do any testing if it's really loaded, fine.
if short_test:
return True
loaded = True
if not self.state.get()["loaded_item"] or not self.isInit:
loaded = False
elif not self.isPlaying:
# If we don't want to do any testing if it's really loaded, fine.
if not short_test:
# Because Pygame/SDL is annoying
# We're not playing now, so we can quickly test run
# If that works, we're loaded.
try:
mixer.music.set_volume(0)
mixer.music.play(0)
except Exception:
try:
mixer.music.set_volume(1)
except Exception:
self.logger.log.exception(
"Failed to reset volume after attempting loaded test."
)
pass
loaded = False
finally:
mixer.music.stop()
# Because Pygame/SDL is annoying
# We're not playing now, so we can quickly test run
# If that works, we're loaded.
try:
mixer.music.set_volume(0)
mixer.music.play(0)
except Exception:
try:
mixer.music.set_volume(1)
except Exception:
self.logger.log.exception(
"Failed to reset volume after attempting loaded test."
)
pass
return False
finally:
mixer.music.stop()
mixer.music.set_volume(1)
return True
self.state.update("loaded", loaded)
return loaded
@property
def isCued(self):
# Don't mess with playback, we only care about if it's supposed to be loaded.
if not self._isLoaded(short_test=True):
if not self.isLoaded:
return False
return (
self.state.get()["pos_true"] == self.state.get()["loaded_item"].cue
@ -156,7 +162,7 @@ class Player:
@property
def status(self):
state = copy.copy(self.state.state)
state = self.state.state
# Not the biggest fan of this, but maybe I'll get a better solution for this later
state["loaded_item"] = (
@ -436,6 +442,9 @@ class Player:
"Failed to find weight: {}".format(weight))
return False
# This item exists, so we're comitting to load this item.
self.state.update("loaded_item", loaded_item)
# The file_manager helper may have pre-downloaded the file already, or we've played it before.
reload = False
if loaded_item.filename == "" or loaded_item.filename is None:
@ -462,10 +471,8 @@ class Player:
loaded_item.filename
)
# We're comitting to load this item.
# Given we've just messed around with filenames etc, update the item again.
self.state.update("loaded_item", loaded_item)
# Given we've just messed around with filenames etc, update the item in the show plan too.
for i in range(len(showplan)):
if showplan[i].weight == weight:
self.state.update("show_plan", index=i, value=loaded_item)
@ -534,6 +541,7 @@ class Player:
# Everything worked, we made it!
# Write the loaded item again once more, to confirm the filename if we've reattempted.
self.state.update("loaded_item", loaded_item)
self._checkIsLoaded()
if loaded_item.cue > 0:
self.seek(loaded_item.cue)
@ -548,6 +556,7 @@ class Player:
# Even though we failed, make sure state is up to date with latest failure.
# We're comitting to load this item.
self.state.update("loaded_item", loaded_item)
self._checkIsLoaded()
return False
@ -895,7 +904,6 @@ class Player:
self._ended()
self.state.update("playing", self.isPlaying)
self.state.update("loaded", self.isLoaded)
self.state.update(
"pos_true",
@ -1003,7 +1011,7 @@ class Player:
self.out_q = out_q
self.logger = LoggingManager(
"Player" + str(channel), debug=package.build_beta)
"Player" + str(channel), debug=package.BETA)
self.api = MyRadioAPI(self.logger, server_state)
@ -1014,6 +1022,8 @@ class Player:
self.__rate_limited_params,
)
self.state.update("start_time", datetime.now().timestamp())
self.state.add_callback(self._send_status)
self.state.update("channel", channel)
@ -1048,18 +1058,23 @@ class Player:
self.logger.log.info(
"Seeking to pos_true: " + str(loaded_state["pos_true"])
)
self.seek(loaded_state["pos_true"])
try:
self.seek(loaded_state["pos_true"])
except error:
self.logger.log.error("Failed to seek on player start. Continuing anyway.")
if loaded_state["playing"] is True:
self.logger.log.info("Resuming playback on init.")
# Use un-pause as we don't want to jump to a new position.
self.unpause()
try:
self.unpause()
except error:
self.logger.log.error("Failed to unpause on player start. Continuing anyway.")
else:
self.logger.log.info("No file was previously loaded to resume.")
try:
while self.running:
time.sleep(0.02)
self._updateState()
self._ping_times()
try:
@ -1085,11 +1100,19 @@ class Player:
except Empty:
# The incomming message queue was empty,
# skip message processing
pass
# If we're getting no messages, sleep.
# But if we do have messages, once we've done with one, we'll check for the next one more quickly.
time.sleep(0.05)
else:
# We got a message.
## Check if we're successfully loaded
# This is here so that we can check often, but not every single loop
# Only when user gives input.
self._checkIsLoaded()
# Output re-inits the mixer, so we can do this any time.
if self.last_msg.startswith("OUTPUT"):
split = self.last_msg.split(":")

@ -1 +1 @@
Subproject commit bddd49f4f7bb8ed5f89a967b5ac8c6d3cb04f059
Subproject commit 238da52d94f9a2e1f476e5c8e155e50dd8519cb7

View file

@ -12,6 +12,7 @@
Date:
October, November 2020
"""
from datetime import datetime
from file_manager import FileManager
import multiprocessing
from multiprocessing.queues import Queue
@ -22,7 +23,7 @@ import json
from setproctitle import setproctitle
import psutil
from helpers.os_environment import isMacOS
from helpers.os_environment import isLinux, isMacOS
if not isMacOS():
# Rip, this doesn't like threading on MacOS.
@ -70,6 +71,7 @@ class BAPSicleServer:
"myradio_api_tracklist_source": "",
"running_state": "running",
"tracklist_mode": "off",
"normalisation_mode": "off",
}
player_to_q: List[Queue] = []
@ -201,7 +203,9 @@ class BAPSicleServer:
time.sleep(1)
def startServer(self):
if isMacOS():
# On MacOS, the default causes something to keep creating new processes.
# On Linux, this is needed to make pulseaudio initiate properly.
if isMacOS() or isLinux():
multiprocessing.set_start_method("spawn", True)
process_title = "startServer"
@ -220,6 +224,7 @@ class BAPSicleServer:
)
self.state.update("running_state", "running")
self.state.update("start_time", datetime.now().timestamp())
print("Launching BAPSicle...")

View file

@ -1,9 +0,0 @@
{% extends 'base.html' %}
{% block content_inner %}
<div class="text-center">
<div class="error error-big mx-auto" data-text="404">404</div>
<p class="lead text-gray-800 mb-5">Page Not Found</p>
<p class="text-gray-900 mb-0">Looks like you fell off the tip of the iceberg.</p>
<a href="/">← Escape Back Home</a>
</div>
{% endblock %}

14
ui-templates/alerts.html Normal file
View file

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% from 'parts/alert_list.html' import alert_list %}
{% block head %}
<meta http-equiv="refresh" content="15;url=/alerts" />
{% endblock %}
{% block content_inner %}
{% if data %}
<h3 class="h4">Current Alerts: {{ data.alerts_current | length }}</h3>
{{ alert_list(data.alerts_current) }}
<hr>
<h3 class="h4">Previous Alerts: {{ data.alerts_previous | length }}</h3>
{{ alert_list(data.alerts_previous) }}
{% endif %}
{% endblock %}

View file

@ -9,26 +9,50 @@
<h4 class="h5">Currently Selected</h4>
<p>
{% for channel in data.channels %}
<strong>Player {{channel.channel}}:</strong>
{% if channel.output %}
{{channel.output}}
<strong>Player {{loop.index0}}:</strong>
{% if channel %}
{% if channel.output %}
{{channel.output}}
{% else %}
Default Audio Device
{% endif %}
{% if not channel.initialised %}
<code> - <strong>ERROR:</strong> Player did not start, potentially configured sound output is missing? Check <a href="/alerts">Alerts</a>.</code>
{% endif %}
{% else %}
Default Audio Device
{% endif %}
{% if not channel.initialised %}
<code> - <strong>ERROR:</strong> Player did not start, potentially missing sound output?</code>
<code> - <strong>ERROR:</strong> Player did not respond, likely it is dead :/ Check <a href="/alerts">Alerts</a>.</code>
{% endif %}
<br>
{% endfor %}
<br/>
<code>
Set for:
{% for channel in data.channels %}
{% for channel in data.channels if channel %}
<a href="/player/{{channel.channel}}/output/None">Player {{channel.channel}}</a> /
{% endfor %}
Default Audio Output
</code>
</p>
{% if data.sdl_direct %}
Linux (Pulse Audio)
<br>
<code>
{% for output in data.outputs %}
Set for:
{% for channel in data.channels %}
{% if not channel %}
Player {{loop.index0}}
{% elif channel.output == output %}
<strong>Player {{channel.channel}}</strong>
{% else %}
<a href="/player/{{channel.channel}}/output/{{output}}">Player {{channel.channel}}</a>
{% endif %}
/
{% endfor %}
{% if output %}{{output}}{% else %}System Default Output{% endif %}<br>
{% endfor %}
</code>
{% else %}
{% for host_api in data.outputs %}
{{host_api.name}}
<br>
@ -37,7 +61,9 @@ Default Audio Output
{% if host_api.usable %}
Set for:
{% for channel in data.channels %}
{% if channel.output == output.name %}
{% if not channel %}
Player {{loop.index0}}
{% elif channel.output == output.name %}
<strong>Player {{channel.channel}}</strong>
{% else %}
<a href="/player/{{channel.channel}}/output/{{output.name}}">Player {{channel.channel}}</a>
@ -48,4 +74,5 @@ Default Audio Output
{% endfor %}
</code>
{% endfor %}
{% endif %}
{% endblock %}

View file

@ -48,6 +48,16 @@
Delayed tracklisting is 20s, to account for cueing with fader down.<br>
Fader Live means if a BAPS Controller is present with support, tracklists will trigger only if fader is up.
</small></p>
<label for="serial_port">Normalisation:</label>
<select class="form-control" name="normalisation_mode">
<label>Modes</label>
{% for mode in data.normalisation_modes %}
<option value="{{mode}}" {% if mode == data.state.normalisation_mode %}selected{% endif %}>{{ mode.capitalize() }}</option>
{% endfor %}
</select>
<p><small>
Normalisation requests significant CPU requirements, if you're finding the CPU usuage is too high / causing audio glitches, disable this feature. <code>ffmpeg</code> or <code>avconf</code> required.
</small></p>
<hr>
<input type="submit" class="btn btn-primary" value="Save & Restart Server">
</form>

9
ui-templates/error.html Normal file
View file

@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block content_inner %}
<div class="text-center">
<div class="error error-big mx-auto" data-text="{{data.code}}">{{data.code}}</div>
<p class="lead text-gray-800 mb-5">{{data.title}}</p>
<p class="text-gray-900 mb-0">{{data.message}}</p>
<a href="/">← Escape Back Home</a>
</div>
{% endblock %}

View file

@ -1,4 +1,7 @@
{% extends 'base.html' %}
{% block head %}
<meta http-equiv="refresh" content="60;url=/" />
{% endblock %}
{% block content %}
<div class="card-body p-0">
@ -16,6 +19,23 @@
<a href="/presenter/" class="btn btn-primary btn-user btn-block">
Open BAPS Presenter
</a>
<hr>
{% if data.alert_count > 0 %}
<div class="alert alert-danger" role="alert">
<h4 class="alert-heading">Something's up! <a href="/alerts" class="btn btn-sm btn-danger float-right"><span class="badge badge-light mr-1">{{data.alert_count}}</span>View Alerts</a></h4>
<p>BAPSicle is having some issues. Please review the alerts page.
{% else %}
<div class="alert alert-success" role="alert">
<h4 class="alert-heading">We're all good!</h4>
<p>BAPSicle seems to be running well. If this doesn't seem to be the case, try restarting or taking a closer look at the logs.</p>
{% endif %}
<hr>
<a href="/logs" class="btn btn-success">Logs</a>
<a href="/restart" class="btn btn-info">Restart</a>
<a href="/quit" class="btn btn-danger">Quit</a>
</div>
<hr>
<p>Version: {{data.server_version}} - Build: {{data.server_build}} - Branch: {{data.server_branch}}</p>
<p>Server Name: {{data.server_name}}</p>

View file

@ -0,0 +1,27 @@
{% macro alert_list(alerts) %}
{% for alert in alerts %}
<div class="card alert-{{ alert.ui_class }}">
<div class="card-header" id="headingOne">
<span class="badge badge-{{ alert.ui_class}}">{{ alert.severity }}</span>
<h4 class="h5 mb-0 mt-1">{{ alert.title }}</h4>
<span class="badge badge-primary">Since {{ alert.start_time | happytime }}</span>
<span class="badge badge-secondary">Last Seen {{ alert.last_time | happytime }}</span>
{% if alert.end_time %}
<span class="badge badge-success">Ended {{ alert.end_time | happytime }}</span>
{% endif %}
</div>
<div id="collapseOne" class="collapse show" aria-labelledby="headingOne" data-parent="#accordionExample">
<div class="card-body pb-0">
<strong>Module: </strong><a href="/logs/{{ alert.module }}" title="Click for logs">{{ alert.module }}</a>
{% autoescape false %}
<p>{{ alert.description | replace("\n\n", "</p><p>") | replace("\n", "<br/>")}}</p>
{% endautoescape %}
</div>
</div>
</div>
{% endfor %}
{% if not alerts %}
<strong>No alerts here.</strong>
{% endif %}
{% endmacro %}

View file

@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block content_inner %}
<div class="text-center">
<p class="lead text-gray-800 mb-2">Hang on a second!</p>
<p class="text-gray-900 mb-3">Something is currently playing. Restarting will interrupt it! Are you sure?</p>
<a href="/status" class="btn btn-info">Cancel</a>
<a href="/restart?confirm=true" class="btn btn-danger">Confirm</a>
</div>
{% endblock %}

View file

@ -12,6 +12,7 @@
<h3 class="h5">Player {{player.channel}}</h3>
<p>
Initialised: {{player.initialised}}<br/>
Successful Load: {{player.loaded}}<br/>
Fader Live: {{player.live}}<br/>
Current Tracklist: {{player.tracklist_id}}
</p>

View file

@ -18,7 +18,7 @@ import json
import os
from helpers.os_environment import (
isBundelled,
isLinux,
resolve_external_file_path,
resolve_local_file_path,
)
@ -28,6 +28,9 @@ from helpers.state_manager import StateManager
from helpers.the_terminator import Terminator
from helpers.normalisation import get_normalised_filename_if_available
from helpers.myradio_api import MyRadioAPI
from helpers.alert_manager import AlertManager
import package
from baps_types.happytime import happytime
env = Environment(
loader=FileSystemLoader("%s/ui-templates/" % os.path.dirname(__file__)),
@ -95,9 +98,16 @@ def render_template(file, data, status=200):
return html(html_content, status=status)
def _filter_happytime(date):
return happytime(date)
env.filters["happytime"] = _filter_happytime
logger: LoggingManager
server_state: StateManager
api: MyRadioAPI
alerts: AlertManager
player_to_q: List[Queue] = []
player_from_q: Queue
@ -107,8 +117,16 @@ player_from_q: Queue
@app.exception(NotFound)
def page_not_found(request, e: Any):
data = {"ui_page": "404", "ui_title": "404"}
return render_template("404.html", data=data, status=404)
data = {"ui_page": "404", "ui_title": "404", "code": 404, "title": "Page Not Found",
"message": "Looks like you fell off the tip of the iceberg."}
return render_template("error.html", data=data, status=404)
# Future use.
def error_page(code=500, ui_title="500", title="Something went very wrong!",
message="Looks like the server fell over. Try viewing the WebServer logs for more details."):
data = {"ui_page": ui_title, "ui_title": ui_title, "code": code, "title": title, "message": message}
return render_template("error.html", data=data, status=500)
@app.route("/")
@ -117,6 +135,7 @@ def ui_index(request):
data = {
"ui_page": "index",
"ui_title": "",
"alert_count": len(alerts.alerts_current),
"server_version": config["server_version"],
"server_build": config["server_build"],
"server_name": config["server_name"],
@ -137,17 +156,33 @@ def ui_status(request):
return render_template("status.html", data=data)
@app.route("/alerts")
def ui_alerts(request):
data = {
"alerts_current": alerts.alerts_current,
"alerts_previous": alerts.alerts_previous,
"ui_page": "alerts",
"ui_title": "Alerts"
}
return render_template("alerts.html", data=data)
@app.route("/config/player")
def ui_config_player(request):
channel_states = []
for i in range(server_state.get()["num_channels"]):
channel_states.append(status(i))
outputs = DeviceManager.getAudioOutputs()
outputs = None
if isLinux():
outputs = DeviceManager.getAudioDevices()
else:
outputs = DeviceManager.getAudioOutputs()
data = {
"channels": channel_states,
"outputs": outputs,
"sdl_direct": isLinux(),
"ui_page": "config",
"ui_title": "Player Config",
}
@ -162,6 +197,7 @@ def ui_config_server(request):
"state": server_state.get(),
"ser_ports": DeviceManager.getSerialPorts(),
"tracklist_modes": ["off", "on", "delayed", "fader-live"],
"normalisation_modes": ["off", "on"],
}
return render_template("config_server.html", data=data)
@ -193,6 +229,7 @@ def ui_config_server_update(request):
"myradio_api_tracklist_source")
)
server_state.update("tracklist_mode", request.form.get("tracklist_mode"))
server_state.update("normalisation_mode", request.form.get("normalisation_mode"))
return redirect("/restart")
@ -218,7 +255,12 @@ def ui_logs_render(request, path):
page = int(page)
assert page >= 1
log_file = open(resolve_external_file_path("/logs/{}.log").format(path))
try:
log_file = open(resolve_external_file_path("/logs/{}.log").format(path))
except FileNotFoundError:
abort(404)
return
data = {
"logs": log_file.read().splitlines()[
-300 * page:(-300 * (page - 1) if page > 1 else None)
@ -393,7 +435,12 @@ async def audio_file(request, type: str, id: int):
filename = get_normalised_filename_if_available(filename)
# Send file or 404
return await file(filename)
try:
response = await file(filename)
except FileNotFoundError:
abort(404)
return
return response
# Static Files
@ -466,6 +513,11 @@ def quit(request):
@app.route("/restart")
def restart(request):
if request.args.get("confirm", '') != "true":
for i in range(server_state.get()["num_channels"]):
state = status(i)
if state["playing"]:
return render_template("restart-confirm.html", data=None)
server_state.update("running_state", "restarting")
data = {
@ -483,13 +535,14 @@ def restart(request):
# Don't use reloader, it causes Nested Processes!
def WebServer(player_to: List[Queue], player_from: Queue, state: StateManager):
global player_to_q, player_from_q, server_state, api, app
global player_to_q, player_from_q, server_state, api, app, alerts
player_to_q = player_to
player_from_q = player_from
server_state = state
logger = LoggingManager("WebServer")
api = MyRadioAPI(logger, state)
alerts = AlertManager()
process_title = "Web Server"
setproctitle(process_title)
@ -503,16 +556,19 @@ def WebServer(player_to: List[Queue], player_from: Queue, state: StateManager):
app.run(
host=server_state.get()["host"],
port=server_state.get()["port"],
debug=(not isBundelled()),
auto_reload=False,
access_log=(not isBundelled()),
debug=not package.BETA,
access_log=not package.BETA,
)
)
except Exception:
break
loop = asyncio.get_event_loop()
if loop:
loop.close()
if app:
app.stop()
del app
try:
loop = asyncio.get_event_loop()
if loop:
loop.close()
if app:
app.stop()
del app
except Exception:
pass

View file

@ -7,7 +7,8 @@ from typing import List
import websockets
import json
from os import _exit
from websockets.server import Serve
from websockets.legacy.server import Serve
from websockets.server import serve
from setproctitle import setproctitle
from multiprocessing import current_process
@ -39,7 +40,7 @@ class WebsocketServer:
self.logger = LoggingManager("Websockets")
self.server_name = state.get()["server_name"]
self.websocket_server = websockets.serve(
self.websocket_server = serve(
self.websocket_handler, state.get()["host"], state.get()["ws_port"]
)