Merge branch 'dev' into mstratford/quitting
Likely to break something, but here goes.
This commit is contained in:
commit
92a6b86ccd
31 changed files with 761 additions and 111 deletions
24
.github/workflows/build.yaml
vendored
24
.github/workflows/build.yaml
vendored
|
@ -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
2
.gitignore
vendored
|
@ -26,4 +26,4 @@ music-tmp/
|
|||
|
||||
presenter-build
|
||||
|
||||
node_modules
|
||||
node_modules/
|
20
alerts/dummy.py
Normal file
20
alerts/dummy.py
Normal 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
99
alerts/player.py
Normal 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
77
alerts/server.py
Normal 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
69
baps_types/alert.py
Normal 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
5
baps_types/happytime.py
Normal file
|
@ -0,0 +1,5 @@
|
|||
from datetime import datetime
|
||||
|
||||
|
||||
def happytime(date: datetime):
|
||||
return date.strftime("%Y-%m-%d %H:%M:%S")
|
|
@ -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
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
#!/bin/bash
|
||||
sudo apt-get -y install libasound-dev libportaudio2
|
||||
sudo apt-get -y install libasound-dev libportaudio2 ffmpeg
|
||||
|
|
|
@ -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
|
||||
|
|
24
dev/scripts/get_linux_outputs.py
Normal file
24
dev/scripts/get_linux_outputs.py
Normal 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()
|
|
@ -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
97
helpers/alert_manager.py
Normal 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]
|
|
@ -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
|
||||
|
|
|
@ -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
12
package-lock.json
generated
|
@ -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=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
103
player.py
|
@ -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
|
|
@ -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...")
|
||||
|
||||
|
|
|
@ -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
14
ui-templates/alerts.html
Normal 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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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
9
ui-templates/error.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
27
ui-templates/parts/alert_list.html
Normal file
27
ui-templates/parts/alert_list.html
Normal 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 %}
|
9
ui-templates/restart-confirm.html
Normal file
9
ui-templates/restart-confirm.html
Normal 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 %}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"]
|
||||
)
|
||||
|
||||
|
|
Loading…
Reference in a new issue