diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index fcd751b..32999fe 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -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 diff --git a/.gitignore b/.gitignore index a8b327a..772a7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ music-tmp/ presenter-build -node_modules +node_modules/ \ No newline at end of file diff --git a/alerts/dummy.py b/alerts/dummy.py new file mode 100644 index 0000000..7dacca3 --- /dev/null +++ b/alerts/dummy.py @@ -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 + } + )] diff --git a/alerts/player.py b/alerts/player.py new file mode 100644 index 0000000..9ed9da8 --- /dev/null +++ b/alerts/player.py @@ -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 diff --git a/alerts/server.py b/alerts/server.py new file mode 100644 index 0000000..d89e186 --- /dev/null +++ b/alerts/server.py @@ -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 + })] diff --git a/baps_types/alert.py b/baps_types/alert.py new file mode 100644 index 0000000..33ddd76 --- /dev/null +++ b/baps_types/alert.py @@ -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 diff --git a/baps_types/happytime.py b/baps_types/happytime.py new file mode 100644 index 0000000..9fb6426 --- /dev/null +++ b/baps_types/happytime.py @@ -0,0 +1,5 @@ +from datetime import datetime + + +def happytime(date: datetime): + return date.strftime("%Y-%m-%d %H:%M:%S") diff --git a/build/build-linux.sh b/build/build-linux.sh index c67ee8d..c5a9ea9 100755 --- a/build/build-linux.sh +++ b/build/build-linux.sh @@ -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 diff --git a/build/install-ubuntu.sh b/build/install-ubuntu.sh index fc5ec29..fe92ef4 100755 --- a/build/install-ubuntu.sh +++ b/build/install-ubuntu.sh @@ -1,2 +1,2 @@ #!/bin/bash -sudo apt-get -y install libasound-dev libportaudio2 +sudo apt-get -y install libasound-dev libportaudio2 ffmpeg diff --git a/build/requirements.txt b/build/requirements.txt index 84c18be..ea525e9 100644 --- a/build/requirements.txt +++ b/build/requirements.txt @@ -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 diff --git a/dev/scripts/get_linux_outputs.py b/dev/scripts/get_linux_outputs.py new file mode 100644 index 0000000..1c53024 --- /dev/null +++ b/dev/scripts/get_linux_outputs.py @@ -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() diff --git a/file_manager.py b/file_manager.py index fa011d1..c19799c 100644 --- a/file_manager.py +++ b/file_manager.py @@ -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 diff --git a/helpers/alert_manager.py b/helpers/alert_manager.py new file mode 100644 index 0000000..b2e2775 --- /dev/null +++ b/helpers/alert_manager.py @@ -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] diff --git a/helpers/device_manager.py b/helpers/device_manager.py index ae5c930..df68409 100644 --- a/helpers/device_manager.py +++ b/helpers/device_manager.py @@ -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 diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py index 2744038..f7bc5d0 100644 --- a/helpers/myradio_api.py +++ b/helpers/myradio_api.py @@ -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"] diff --git a/package-lock.json b/package-lock.json index 939f4ff..ac7f15d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" + } + } } diff --git a/package.json b/package.json index cae6ea8..2d3161e 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/player.py b/player.py index 5e1ca31..a708c04 100644 --- a/player.py +++ b/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(":") diff --git a/presenter b/presenter index bddd49f..238da52 160000 --- a/presenter +++ b/presenter @@ -1 +1 @@ -Subproject commit bddd49f4f7bb8ed5f89a967b5ac8c6d3cb04f059 +Subproject commit 238da52d94f9a2e1f476e5c8e155e50dd8519cb7 diff --git a/server.py b/server.py index bcddc9a..3f59096 100644 --- a/server.py +++ b/server.py @@ -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...") diff --git a/ui-templates/404.html b/ui-templates/404.html deleted file mode 100644 index 5c3bae0..0000000 --- a/ui-templates/404.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends 'base.html' %} -{% block content_inner %} -
-
404
-

Page Not Found

-

Looks like you fell off the tip of the iceberg.

- ← Escape Back Home -
-{% endblock %} diff --git a/ui-templates/alerts.html b/ui-templates/alerts.html new file mode 100644 index 0000000..c90fd60 --- /dev/null +++ b/ui-templates/alerts.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% from 'parts/alert_list.html' import alert_list %} +{% block head %} + +{% endblock %} +{% block content_inner %} + {% if data %} +

Current Alerts: {{ data.alerts_current | length }}

+ {{ alert_list(data.alerts_current) }} +
+

Previous Alerts: {{ data.alerts_previous | length }}

+ {{ alert_list(data.alerts_previous) }} + {% endif %} +{% endblock %} diff --git a/ui-templates/config_player.html b/ui-templates/config_player.html index 1c09a90..6ddbfd3 100644 --- a/ui-templates/config_player.html +++ b/ui-templates/config_player.html @@ -9,26 +9,50 @@

Currently Selected

{% for channel in data.channels %} - Player {{channel.channel}}: - {% if channel.output %} - {{channel.output}} + Player {{loop.index0}}: + {% if channel %} + {% if channel.output %} + {{channel.output}} + {% else %} + Default Audio Device + {% endif %} + {% if not channel.initialised %} + - ERROR: Player did not start, potentially configured sound output is missing? Check Alerts. + {% endif %} {% else %} - Default Audio Device - {% endif %} - {% if not channel.initialised %} - - ERROR: Player did not start, potentially missing sound output? + - ERROR: Player did not respond, likely it is dead :/ Check Alerts. {% endif %}
{% endfor %}
Set for: -{% for channel in data.channels %} +{% for channel in data.channels if channel %} Player {{channel.channel}} / {% endfor %} Default Audio Output

+{% if data.sdl_direct %} +Linux (Pulse Audio) +
+ +{% for output in data.outputs %} +Set for: + {% for channel in data.channels %} + {% if not channel %} + Player {{loop.index0}} + {% elif channel.output == output %} + Player {{channel.channel}} + {% else %} + Player {{channel.channel}} + {% endif %} + / + {% endfor %} +{% if output %}{{output}}{% else %}System Default Output{% endif %}
+{% endfor %} +
+{% else %} {% for host_api in data.outputs %} {{host_api.name}}
@@ -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 %} Player {{channel.channel}} {% else %} Player {{channel.channel}} @@ -48,4 +74,5 @@ Default Audio Output {% endfor %} {% endfor %} +{% endif %} {% endblock %} diff --git a/ui-templates/config_server.html b/ui-templates/config_server.html index 6c9ee7f..18a2852 100644 --- a/ui-templates/config_server.html +++ b/ui-templates/config_server.html @@ -48,6 +48,16 @@ Delayed tracklisting is 20s, to account for cueing with fader down.
Fader Live means if a BAPS Controller is present with support, tracklists will trigger only if fader is up.

+ + +

+ Normalisation requests significant CPU requirements, if you're finding the CPU usuage is too high / causing audio glitches, disable this feature. ffmpeg or avconf required. +


diff --git a/ui-templates/error.html b/ui-templates/error.html new file mode 100644 index 0000000..00730d2 --- /dev/null +++ b/ui-templates/error.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block content_inner %} +
+
{{data.code}}
+

{{data.title}}

+

{{data.message}}

+ ← Escape Back Home +
+{% endblock %} diff --git a/ui-templates/index.html b/ui-templates/index.html index 53c5aa4..52ebd66 100644 --- a/ui-templates/index.html +++ b/ui-templates/index.html @@ -1,4 +1,7 @@ {% extends 'base.html' %} +{% block head %} + +{% endblock %} {% block content %}
@@ -16,6 +19,23 @@ Open BAPS Presenter + +
+ {% if data.alert_count > 0 %} +