From 432c645759361d30c5ad448d5003881a1a114307 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Wed, 22 Sep 2021 23:53:30 +0100 Subject: [PATCH 01/23] Fix returning OKAY to play when unloaded. Closes #16 --- player.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/player.py b/player.py index 2f70f31..da5477d 100644 --- a/player.py +++ b/player.py @@ -199,9 +199,7 @@ class Player: if not self.isPlaying: state = self.state.get() position: float = state["pos_true"] - try: - self.play(position) - except Exception: + if not self.play(position): self.logger.log.exception( "Failed to unpause from pos: " + str(position) ) From 5103023e85f8880e151ececf44cc81aee28fb040 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Wed, 22 Sep 2021 23:57:50 +0100 Subject: [PATCH 02/23] Try non-normalised / redownload file on failure to load. --- helpers/myradio_api.py | 82 +++++++++++++++++++++------------------- helpers/normalisation.py | 21 ++++++++++ player.py | 79 +++++++++++++++++++++++++++----------- 3 files changed, 121 insertions(+), 61 deletions(-) diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py index f1cb32a..2744038 100644 --- a/helpers/myradio_api.py +++ b/helpers/myradio_api.py @@ -60,6 +60,7 @@ class MyRadioAPI: + str(response.status) ) self._logException(str(await response.text())) + return None # Given the output was bad, don't forward it. return await response.read() def call(self, url, method="GET", data=None, timeout=10, json_payload=True): @@ -102,12 +103,12 @@ class MyRadioAPI: request = None if method == "GET": - request = self.async_call(url, method="GET", timeout=timeout) + request = await self.async_call(url, method="GET", timeout=timeout) elif method == "POST": self._log("POST data: {}".format(data)) - request = self.async_call(url, data=data, method="POST", timeout=timeout) + request = await self.async_call(url, data=data, method="POST", timeout=timeout) elif method == "PUT": - request = self.async_call(url, method="PUT", timeout=timeout) + request = await self.async_call(url, method="PUT", timeout=timeout) else: self._logException("Invalid API method. Request not sent.") return None @@ -157,7 +158,7 @@ class MyRadioAPI: self._logException("Failed to get list of show plans.") return None - payload = json.loads(await request)["payload"] + payload = json.loads(request)["payload"] shows = [] if not payload["current"]: @@ -186,7 +187,7 @@ class MyRadioAPI: self._logException("Failed to get show plan.") return None - payload = json.loads(await request)["payload"] + payload = json.loads(request)["payload"] plan = {} @@ -203,7 +204,7 @@ class MyRadioAPI: # Audio Library - async def get_filename(self, item: PlanItem, did_download: bool = False): + async def get_filename(self, item: PlanItem, did_download: bool = False, redownload=False): format = "mp3" # TODO: Maybe we want this customisable? if item.trackid: itemType = "track" @@ -234,28 +235,30 @@ class MyRadioAPI: filename: str = resolve_external_file_path( "/music-tmp/{}-{}.{}".format(itemType, id, format) ) - # Check if we already downloaded the file. If we did, give that. - if os.path.isfile(filename): - self._log("Already got file: " + filename, DEBUG) - return (filename, False) if did_download else filename - # If something else (another channel, the preloader etc) is downloading the track, wait for it. - if os.path.isfile(filename + dl_suffix): - time_waiting_s = 0 - self._log( - "Waiting for download to complete from another worker. " + filename, - DEBUG, - ) - while time_waiting_s < 20: - # TODO: Make something better here. - # If the connectivity is super poor or we're loading reeaaaalllly long files, - # this may be annoying, but this is just in case somehow the other api download gives up. - if os.path.isfile(filename): - # Now the file is downloaded successfully - return (filename, False) if did_download else filename - time_waiting_s += 1 - self._log("Still waiting", DEBUG) - time.sleep(1) + if not redownload: + # Check if we already downloaded the file. If we did, give that, unless we're forcing a redownload. + if os.path.isfile(filename): + self._log("Already got file: " + filename, DEBUG) + return (filename, False) if did_download else filename + + # If something else (another channel, the preloader etc) is downloading the track, wait for it. + if os.path.isfile(filename + dl_suffix): + time_waiting_s = 0 + self._log( + "Waiting for download to complete from another worker. " + filename, + DEBUG, + ) + while time_waiting_s < 20: + # TODO: Make something better here. + # If the connectivity is super poor or we're loading reeaaaalllly long files, + # this may be annoying, but this is just in case somehow the other api download gives up. + if os.path.isfile(filename): + # Now the file is downloaded successfully + return (filename, False) if did_download else filename + time_waiting_s += 1 + self._log("Still waiting", DEBUG) + time.sleep(1) # File doesn't exist, download it. try: @@ -267,17 +270,18 @@ class MyRadioAPI: request = await self.async_api_call(url, api_version="non") - if not request: + if not request or not isinstance(request, (bytes, bytearray)): return (None, False) if did_download else None try: with open(filename + dl_suffix, "wb") as file: - file.write(await request) + file.write(request) os.rename(filename + dl_suffix, filename) except Exception as e: self._logException("Failed to write music file: {}".format(e)) return (None, False) if did_download else None + self._log("Successfully re/downloaded file.", DEBUG) return (filename, True) if did_download else filename # Gets the list of managed music playlists. @@ -285,22 +289,22 @@ class MyRadioAPI: url = "/playlist/allitonesplaylists" request = await self.async_api_call(url) - if not request: + if not request or not isinstance(request, bytes): self._logException("Failed to retrieve music playlists.") return None - return json.loads(await request)["payload"] + return json.loads(request)["payload"] # Gets the list of managed aux playlists (sfx, beds etc.) async def get_playlist_aux(self): url = "/nipswebPlaylist/allmanagedplaylists" request = await self.async_api_call(url) - if not request: + if not request or not isinstance(request, bytes): self._logException("Failed to retrieve music playlists.") return None - return json.loads(await request)["payload"] + return json.loads(request)["payload"] # Loads the playlist items for a certain managed aux playlist async def get_playlist_aux_items(self, library_id: str): @@ -311,13 +315,13 @@ class MyRadioAPI: url = "/nipswebPlaylist/{}/items".format(library_id) request = await self.async_api_call(url) - if not request: + if not request or not isinstance(request, bytes): self._logException( "Failed to retrieve items for aux playlist {}.".format(library_id) ) return None - return json.loads(await request)["payload"] + return json.loads(request)["payload"] # Loads the playlist items for a certain managed playlist @@ -325,13 +329,13 @@ class MyRadioAPI: url = "/playlist/{}/tracks".format(library_id) request = await self.async_api_call(url) - if not request: + if not request or not isinstance(request, bytes): self._logException( "Failed to retrieve items for music playlist {}.".format(library_id) ) return None - return json.loads(await request)["payload"] + return json.loads(request)["payload"] async def get_track_search( self, title: Optional[str], artist: Optional[str], limit: int = 100 @@ -341,11 +345,11 @@ class MyRadioAPI: ) request = await self.async_api_call(url) - if not request: + if not request or not isinstance(request, bytes): self._logException("Failed to search for track.") return None - return json.loads(await request)["payload"] + return json.loads(request)["payload"] def post_tracklist_start(self, item: PlanItem): if item.type != "central": diff --git a/helpers/normalisation.py b/helpers/normalisation.py index c7a8e1e..2168361 100644 --- a/helpers/normalisation.py +++ b/helpers/normalisation.py @@ -44,3 +44,24 @@ def get_normalised_filename_if_available(filename: str): # Else we've not got a normalised verison, just take original. return filename + + +# Returns the original file from the normalised one, useful if the normalised one is borked. +def get_original_filename_from_normalised(filename: str): + if not (isinstance(filename, str) and filename.endswith(".mp3")): + raise ValueError("Invalid filename given.") + + # Already not normalised. + if not filename.endswith("-normalised.mp3"): + if os.path.exists(filename): + return filename + return None + + # Take the filename, remove "-normalised" from it. + original_filename = "{}.mp3".format(filename.rsplit("-", 1)[0]) + + # original version exists + if os.path.exists(original_filename): + return original_filename + + return None diff --git a/player.py b/player.py index da5477d..d622303 100644 --- a/player.py +++ b/player.py @@ -36,7 +36,7 @@ from mutagen.mp3 import MP3 from syncer import sync from threading import Timer -from helpers.normalisation import get_normalised_filename_if_available +from helpers.normalisation import get_normalised_filename_if_available, get_original_filename_from_normalised from helpers.myradio_api import MyRadioAPI from helpers.state_manager import StateManager from helpers.logging_manager import LoggingManager @@ -170,9 +170,10 @@ class Player: # Audio Playout Related Methods def play(self, pos: float = 0): - if not self.isLoaded: - return self.logger.log.info("Playing from pos: " + str(pos)) + if not self.isLoaded: + self.logger.log.warning("Player is not loaded.") + return False try: mixer.music.play(0, pos) self.state.update("pos_offset", pos) @@ -329,7 +330,7 @@ class Player: # Right. So this may be confusing. # So... If the user has just moved the loaded item in the channel (by removing above and readding) # Then we want to re-associate the loaded_item object reference with the new one. - # The loaded item object before this change is now an ophan, which was + # The loaded item object before this change is now an orphan, which was # kept around while the loaded item was potentially moved to another # channel. if loaded_item.timeslotitemid == new_item_obj.timeslotitemid: @@ -406,9 +407,13 @@ class Player: def load(self, weight: int): if not self.isPlaying: - loaded_state = self.state.get() + # If we have something loaded already, unload it first. self.unload() + loaded_state = self.state.get() + + # Sometimes (at least on windows), the pygame player will lose output to the sound output after a while. + # It's odd, but essentially, to stop / recover from this, we de-init the pygame mixer and init it again. self.logger.log.info( "Resetting output (in case of sound output gone silent somehow) to " + str(loaded_state["output"]) @@ -419,16 +424,19 @@ class Player: loaded_item: Optional[PlanItem] = None + # Go find the show plan item of the weight we've been asked to load. for i in range(len(showplan)): if showplan[i].weight == weight: loaded_item = showplan[i] break + # If we didn't find it, exit. if loaded_item is None: self.logger.log.error( "Failed to find weight: {}".format(weight)) return False + # 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: self.logger.log.info( @@ -440,10 +448,12 @@ class Player: ) reload = True + # Ask the API for the file if we need it. if reload: - loaded_item.filename = sync( - self.api.get_filename(item=loaded_item)) + file = sync(self.api.get_filename(item=loaded_item)) + loaded_item.filename = str(file) if file else None + # If the API still couldn't get the file, RIP. if not loaded_item.filename: return False @@ -452,38 +462,57 @@ class Player: loaded_item.filename ) + # We're comitting to load this item. 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) break - # TODO: Update the show plan filenames??? load_attempt = 0 - if not isinstance(loaded_item.filename, str): - return False - + # Let's have 5 attempts at loading the item audio while load_attempt < 5: load_attempt += 1 + + original_file = None + if load_attempt == 3: + # Ok, we tried twice already to load the file. + # Let's see if we can recover from this. + # Try swapping the normalised version out for the original. + original_file = get_original_filename_from_normalised( + loaded_item.filename + ) + self.logger.log.warning("3rd attempt. Trying the non-normalised file: {}".format(original_file)) + + if load_attempt == 4: + # well, we've got so far that the normalised and original files didn't load. + # Take a last ditch effort to download the original file again. + file = sync(self.api.get_filename(item=loaded_item, redownload=True)) + if file: + original_file = str(file) + self.logger.log.warning("4rd attempt. Trying to redownload the file, got: {}".format(original_file)) + + if original_file: + loaded_item.filename = original_file + try: self.logger.log.info( - "Loading file: " + str(loaded_item.filename)) + "Attempt {} Loading file: {}".format(load_attempt, loaded_item.filename)) mixer.music.load(loaded_item.filename) except Exception: # We couldn't load that file. self.logger.log.exception( "Couldn't load file: " + str(loaded_item.filename) ) - time.sleep(1) continue # Try loading again. if not self.isLoaded: self.logger.log.error( "Pygame loaded file without error, but never actually loaded." ) - time.sleep(1) continue # Try loading again. try: @@ -500,23 +529,25 @@ class Player: except Exception: self.logger.log.exception( "Failed to update the length of item.") - time.sleep(1) continue # Try loading again. # 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) + if loaded_item.cue > 0: self.seek(loaded_item.cue) else: self.seek(0) - if self.state.get()["play_on_load"]: + if loaded_state["play_on_load"]: self.unpause() return True - self.logger.log.error( - "Failed to load track after numerous retries.") - return False + # 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) return False @@ -546,8 +577,10 @@ class Player: self.logger.log.exception("Failed to quit mixer.") def output(self, name: Optional[str] = None): - wasPlaying = self.state.get()["playing"] - oldPos = self.state.get()["pos_true"] + wasPlaying = self.isPlaying + + state = self.state.get() + oldPos = state["pos_true"] name = None if (not name or name.lower() == "none") else name @@ -564,7 +597,7 @@ class Player: ) return False - loadedItem = self.state.get()["loaded_item"] + loadedItem = state["loaded_item"] if loadedItem: self.logger.log.info("Reloading after output change.") self.load(loadedItem.weight) @@ -799,6 +832,8 @@ class Player: loaded_item.name, loaded_item.weight ) ) + # Just make sure that if we stop and do nothing, we end up at 0. + self.state.update("pos",0) # Repeat 1 # TODO ENUM From 84f0bae69b41448bd9756f3887f9c4474aaf3fab Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 00:02:12 +0100 Subject: [PATCH 03/23] Add better linting and 3.1.0 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 6ac33ba..cae6ea8 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bapsicle", "nice_name": "BAPSicle", - "version": "3.0.0", + "version": "3.1.0", "description": "BAPS3, the third generation of University Radio York's Broadcast and Presenting Suite. This package includes the Server (BAPSicle) and Presenter (WebStudio)", "main": "index.js", "directories": { @@ -13,7 +13,7 @@ "presenter-make": "npm run presenter-install && (rm -r presenter-build || true) && cd presenter && yarn build-baps && cp -r build ../presenter-build && cd ../ && npm install", "test": "echo \"Error: no test specified\" && exit 1", "presenter-start": "cd presenter && yarn start-baps", - "lint": "autopep8 -r -a -a --ignore E402,E226,E24,W50,W690 --max-line-length 127 --in-place --exclude=\"*node_modules*,*venv/*,presenter/*\" . " + "lint": "./venv/bin/autopep8 -r -a -a --ignore E402,E226,E24,W50,W690 --max-line-length 127 --in-place --exclude=\"*node_modules*,*venv/*,presenter/*\" . && ./venv/bin/flake8 . --exclude=\"*node_modules*,*venv/*,presenter/*\" --count --ignore=E402,E226,E24,W50,W690 --max-complexity=25 --max-line-length=127 --statistics" }, "repository": { "type": "git", From 827b7c685f329ad62144eb9b877e19403b332003 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 00:03:36 +0100 Subject: [PATCH 04/23] Little lint --- player.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/player.py b/player.py index d622303..2120241 100644 --- a/player.py +++ b/player.py @@ -833,7 +833,7 @@ class Player: ) ) # Just make sure that if we stop and do nothing, we end up at 0. - self.state.update("pos",0) + self.state.update("pos", 0) # Repeat 1 # TODO ENUM From b79a4a6806e9a19064ae0e04f0f2ec589e94355b Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 00:30:09 +0100 Subject: [PATCH 05/23] Try unique package names per build. --- .github/workflows/build.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2cf7c09..87ea24c 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,6 +1,7 @@ name: Package on: [push] - +env: + version: ${{github.base_ref}}-${{github.sha}} jobs: build-macos: @@ -25,7 +26,7 @@ jobs: - name: Archive Build uses: actions/upload-artifact@v2 with: - name: Package - MacOS + name: BAPSicle-${{ env.version }}-MacOS path: | build/output/BAPSicle.zip @@ -51,7 +52,7 @@ jobs: - name: Archive Build uses: actions/upload-artifact@v2 with: - name: Package - Ubuntu + name: BAPSicle-${{ env.version }}-Ubuntu path: | build/output/BAPSicle @@ -77,7 +78,7 @@ jobs: - name: Archive Build uses: actions/upload-artifact@v2 with: - name: Package - Windows + name: BAPSicle-${{ env.version }}-Windows path: | build/output/BAPSicle.exe install/ From dd0c3f340cea94c0c452eeb4776e80528f2489dd Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 00:46:16 +0100 Subject: [PATCH 06/23] Try other refs. --- .github/workflows/build.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 87ea24c..514c6c6 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,7 +1,8 @@ name: Package -on: [push] +on: [push,pull_request] + env: - version: ${{github.base_ref}}-${{github.sha}} + version: ${{github.head_ref}}${{github.ref}} jobs: build-macos: From 84282987b5303ab553c2ab404a12ef31a8e34670 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 00:51:04 +0100 Subject: [PATCH 07/23] Just pull request with sha. --- .github/workflows/build.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 514c6c6..169b869 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,8 +1,8 @@ name: Package -on: [push,pull_request] +on: [pull_request] env: - version: ${{github.head_ref}}${{github.ref}} + version: ${{github.head_ref}-${{github.sha}} jobs: build-macos: From 485a79934b1f96469791e204c88ab22bed87f5fc Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 01:09:06 +0100 Subject: [PATCH 08/23] Try getting branch via bash. --- .github/workflows/build.yaml | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 169b869..e163552 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -1,8 +1,6 @@ name: Package -on: [pull_request] +on: [push] -env: - version: ${{github.head_ref}-${{github.sha}} jobs: build-macos: @@ -24,10 +22,14 @@ jobs: npm run presenter-make build/build-macos.sh zip -r build/output/BAPSicle.zip build/output/BAPSicle.app + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch - name: Archive Build uses: actions/upload-artifact@v2 with: - name: BAPSicle-${{ env.version }}-MacOS + name: BAPSicle-${{ steps.extract_branch.outputs.branch }}-${{github.sha}}-MacOS path: | build/output/BAPSicle.zip @@ -50,10 +52,14 @@ jobs: run: | npm run presenter-make build/build-linux.sh + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch - name: Archive Build uses: actions/upload-artifact@v2 with: - name: BAPSicle-${{ env.version }}-Ubuntu + name: BAPSicle-${{ steps.extract_branch.outputs.branch }}-${{github.sha}}-Ubuntu path: | build/output/BAPSicle @@ -76,10 +82,14 @@ jobs: run: | npm run presenter-make build/build-windows.bat no-venv + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch - name: Archive Build uses: actions/upload-artifact@v2 with: - name: BAPSicle-${{ env.version }}-Windows + name: BAPSicle-${{ steps.extract_branch.outputs.branch }}-${{github.sha}}-Windows path: | build/output/BAPSicle.exe install/ From 076440d7c81501ae5e443f854246ec59ecf7a963 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Thu, 23 Sep 2021 01:20:12 +0100 Subject: [PATCH 09/23] Get branch without user prefix (can't have / in filename) --- .github/workflows/build.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index e163552..fcd751b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -24,7 +24,7 @@ jobs: zip -r build/output/BAPSicle.zip build/output/BAPSicle.app - name: Extract branch name shell: bash - run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})" id: extract_branch - name: Archive Build uses: actions/upload-artifact@v2 @@ -54,7 +54,7 @@ jobs: build/build-linux.sh - name: Extract branch name shell: bash - run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})" id: extract_branch - name: Archive Build uses: actions/upload-artifact@v2 @@ -84,7 +84,7 @@ jobs: build/build-windows.bat no-venv - name: Extract branch name shell: bash - run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF##*/})" id: extract_branch - name: Archive Build uses: actions/upload-artifact@v2 From 212f449e13ec81382fb11fdca2961ac576d0d252 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 18:51:30 +0100 Subject: [PATCH 10/23] Show loaded status --- ui-templates/status.html | 1 + 1 file changed, 1 insertion(+) diff --git a/ui-templates/status.html b/ui-templates/status.html index 17482b5..5b9fc68 100644 --- a/ui-templates/status.html +++ b/ui-templates/status.html @@ -12,6 +12,7 @@

Player {{player.channel}}

Initialised: {{player.initialised}}
+ Successful Load: {{player.loaded}}
Fader Live: {{player.live}}
Current Tracklist: {{player.tracklist_id}}

From 2b2e77fb2798143d3742e2645793d3cf580fcb5e Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 19:25:23 +0100 Subject: [PATCH 11/23] Fix borked API, makes webstudio not white screen. --- helpers/myradio_api.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py index 2744038..0573ba1 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 @@ -291,7 +296,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 +307,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 +324,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 +338,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 +352,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"] From 1b6b3aa9c88228b7a25f9562154ba1fbecc55ec3 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 19:26:04 +0100 Subject: [PATCH 12/23] 404 correctly on missing audio files. --- web_server.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web_server.py b/web_server.py index 142db8a..a66e147 100644 --- a/web_server.py +++ b/web_server.py @@ -426,7 +426,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 From 49590dcc6c396c9e648001b78d99cc2abfe62c35 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 19:26:37 +0100 Subject: [PATCH 13/23] Fix a failed download leaving temp file. --- helpers/myradio_api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/helpers/myradio_api.py b/helpers/myradio_api.py index 0573ba1..f7bc5d0 100644 --- a/helpers/myradio_api.py +++ b/helpers/myradio_api.py @@ -276,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: From 6cfded26cce195bc539dfd7441fdebc0ace78602 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 19:28:13 +0100 Subject: [PATCH 14/23] Semi-fix load failed error in webstudio not showing If a load failed immediately, webstudio didn't see anything loaded, so doesn't report it failed. --- player.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/player.py b/player.py index 7e3411e..c88035c 100644 --- a/player.py +++ b/player.py @@ -437,6 +437,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: @@ -463,10 +466,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) From 3bf05b2783ed7f850441f2882915edfe932413a4 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 20:06:09 +0100 Subject: [PATCH 15/23] Fix preloader not preloading due to GET_PLAN change to GETPLAN --- player_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/player_handler.py b/player_handler.py index da17b64..4cc7ea7 100644 --- a/player_handler.py +++ b/player_handler.py @@ -30,7 +30,7 @@ class PlayerHandler: command = message.split(":")[1] # Let the file manager manage the files based on status and loading new show plan triggers. - if command == "GET_PLAN" or command == "STATUS": + if command == "GETPLAN" or command == "STATUS": file_to_q[channel].put(message) # TODO ENUM From 8cc342b032a3727e88c470d76a1cfa4676482b55 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Sat, 25 Sep 2021 20:06:51 +0100 Subject: [PATCH 16/23] 3.1.0 again? --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 939f4ff..addf7be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { "name": "bapsicle", - "version": "3.0.0", + "version": "3.1.0", "lockfileVersion": 1 } From 1ee542ea3ec06739a027dc27f7ef8f88373fd906 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 12 Oct 2021 20:45:29 +0100 Subject: [PATCH 17/23] Fix linux to use pulseaudio. --- .gitignore | 2 ++ dev/scripts/get_linux_outputs.py | 24 ++++++++++++++++++++++++ helpers/device_manager.py | 14 ++++++++------ package-lock.json | 12 ++++++++++-- package.json | 5 ++++- player.py | 5 ++++- server.py | 6 ++++-- web_server.py | 15 +++++++++------ 8 files changed, 65 insertions(+), 18 deletions(-) create mode 100644 dev/scripts/get_linux_outputs.py diff --git a/.gitignore b/.gitignore index 66518ac..772a7f1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ music-tmp/ presenter-build + +node_modules/ \ No newline at end of file diff --git a/dev/scripts/get_linux_outputs.py b/dev/scripts/get_linux_outputs.py new file mode 100644 index 0000000..323b6f2 --- /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() \ No newline at end of file diff --git a/helpers/device_manager.py b/helpers/device_manager.py index ae5c930..a7871f9 100644 --- a/helpers/device_manager.py +++ b/helpers/device_manager.py @@ -20,10 +20,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 +31,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 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 5230432..0574168 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 diff --git a/server.py b/server.py index 82dde6e..4734dd4 100644 --- a/server.py +++ b/server.py @@ -23,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. @@ -206,7 +206,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" diff --git a/web_server.py b/web_server.py index 142db8a..1ae5068 100644 --- a/web_server.py +++ b/web_server.py @@ -540,9 +540,12 @@ def WebServer(player_to: List[Queue], player_from: List[Queue], state: StateMana ) 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: + pass From e4cc6f7b61d8df6bcc79ecf41a25419323baa3b2 Mon Sep 17 00:00:00 2001 From: Marks Polakovs Date: Wed, 20 Oct 2021 20:49:19 +0100 Subject: [PATCH 18/23] Prompt before restarting the server if anything is playing --- ui-templates/restart-confirm.html | 9 +++++++++ web_server.py | 5 +++++ 2 files changed, 14 insertions(+) create mode 100644 ui-templates/restart-confirm.html diff --git a/ui-templates/restart-confirm.html b/ui-templates/restart-confirm.html new file mode 100644 index 0000000..3f7b3c6 --- /dev/null +++ b/ui-templates/restart-confirm.html @@ -0,0 +1,9 @@ +{% extends 'base.html' %} +{% block content_inner %} +
+

Hang on a second!

+

Something is currently playing. Restarting will interrupt it! Are you sure?

+ Cancel + Confirm +
+{% endblock %} diff --git a/web_server.py b/web_server.py index 142db8a..04ed686 100644 --- a/web_server.py +++ b/web_server.py @@ -496,6 +496,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 = { From ecbfaa4a62955bef69b5818f9c352a36960bfa5e Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 2 Nov 2021 22:46:41 +0000 Subject: [PATCH 19/23] use sudo to install audio pkg --- build/build-linux.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/build-linux.sh b/build/build-linux.sh index c67ee8d..9b27b8a 100755 --- a/build/build-linux.sh +++ b/build/build-linux.sh @@ -7,7 +7,7 @@ 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 python3 -m venv ../venv source ../venv/bin/activate @@ -19,6 +19,8 @@ pip3 install -e ../ python3 ./generate-build-exe-config.py +chmod +x output/BAPSicle + python3 ./build-exe.py bash ./build-exe-pyinstaller-command.sh From 592cf11a79141bedf015491214bbd6655c430263 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 2 Nov 2021 22:47:24 +0000 Subject: [PATCH 20/23] Fix mp3 support on linux with pygame 2.0.1 --- build/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/requirements.txt b/build/requirements.txt index 84c18be..015bd70 100644 --- a/build/requirements.txt +++ b/build/requirements.txt @@ -1,5 +1,5 @@ wheel -pygame==2.0.1 +pygame==2.0.2 sanic==21.3.4 sanic-Cors==1.0.0 syncer==1.3.0 From c475dbb5d5d72e87db75885a76aea4ff8e24de65 Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 2 Nov 2021 22:48:52 +0000 Subject: [PATCH 21/23] Switch to SDL outputs with pulseaudio for linux --- dev/scripts/get_linux_outputs.py | 2 +- helpers/device_manager.py | 15 +++++++++++++++ ui-templates/config_player.html | 21 +++++++++++++++++++++ web_server.py | 8 +++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/dev/scripts/get_linux_outputs.py b/dev/scripts/get_linux_outputs.py index 323b6f2..6449302 100644 --- a/dev/scripts/get_linux_outputs.py +++ b/dev/scripts/get_linux_outputs.py @@ -5,7 +5,7 @@ os.putenv('SDL_AUDIODRIVER', 'pulseaudio') import pygame._sdl2 as sdl2 import pygame from pygame import mixer -#pygame.init() +pygame.init() import time mixer.init(44100, -16, 2, 1024) is_capture = 0 # zero to request playback devices, non-zero to request recording devices diff --git a/helpers/device_manager.py b/helpers/device_manager.py index a7871f9..aa5a7b0 100644 --- a/helpers/device_manager.py +++ b/helpers/device_manager.py @@ -1,6 +1,12 @@ 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" +os.putenv('SDL_AUDIODRIVER', 'pulseaudio') +import pygame._sdl2 as sdl2 +from pygame import mixer import glob if isWindows(): @@ -53,6 +59,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/ui-templates/config_player.html b/ui-templates/config_player.html index 78d2c79..6ddbfd3 100644 --- a/ui-templates/config_player.html +++ b/ui-templates/config_player.html @@ -33,6 +33,26 @@ Set for: 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}}
@@ -54,4 +74,5 @@ Default Audio Output {% endfor %} {% endfor %} +{% endif %} {% endblock %} diff --git a/web_server.py b/web_server.py index 1ae5068..5b809ef 100644 --- a/web_server.py +++ b/web_server.py @@ -17,6 +17,7 @@ import json import os from helpers.os_environment import ( + isLinux, resolve_external_file_path, resolve_local_file_path, ) @@ -171,11 +172,16 @@ def ui_config_player(request): 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", } From 65944e59b396bd0dd5a2cd50cd4494dfdaeb49da Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 2 Nov 2021 23:02:09 +0000 Subject: [PATCH 22/23] Fix lints. --- dev/scripts/get_linux_outputs.py | 6 +++--- web_server.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/dev/scripts/get_linux_outputs.py b/dev/scripts/get_linux_outputs.py index 6449302..1c53024 100644 --- a/dev/scripts/get_linux_outputs.py +++ b/dev/scripts/get_linux_outputs.py @@ -18,7 +18,7 @@ for i in names: 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() + # my_song = mixer.Sound("/home/mstratford/Downloads/managed_play.mp3") + # my_song.play() time.sleep(5) - pygame.quit() \ No newline at end of file + pygame.quit() diff --git a/web_server.py b/web_server.py index 5b809ef..83acc14 100644 --- a/web_server.py +++ b/web_server.py @@ -181,7 +181,7 @@ def ui_config_player(request): data = { "channels": channel_states, "outputs": outputs, - "sdl_direct": isLinux(), + "sdl_direct": isLinux(), "ui_page": "config", "ui_title": "Player Config", } @@ -553,5 +553,5 @@ def WebServer(player_to: List[Queue], player_from: List[Queue], state: StateMana if app: app.stop() del app - except: + except Exception: pass From 0f25345a6a10bdd925263ed008c514f8c2b509bb Mon Sep 17 00:00:00 2001 From: Matthew Stratford Date: Tue, 2 Nov 2021 23:26:26 +0000 Subject: [PATCH 23/23] Fix pulseaudio error on !linux --- helpers/device_manager.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/helpers/device_manager.py b/helpers/device_manager.py index aa5a7b0..df68409 100644 --- a/helpers/device_manager.py +++ b/helpers/device_manager.py @@ -4,7 +4,8 @@ from helpers.os_environment import isLinux, isMacOS, isWindows import os os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" -os.putenv('SDL_AUDIODRIVER', 'pulseaudio') +if isLinux(): + os.putenv('SDL_AUDIODRIVER', 'pulseaudio') import pygame._sdl2 as sdl2 from pygame import mixer import glob