A big set of learning since the last update.
I’ve been working on getting the RadioSpiral infrastructure back up to snuff after our Azuracast streaming server upgrade. We really, really did need to do that — it just provides 90% of everything we need to run the station easily. Not having to regenerate the playlists every few weeks is definitely a win, and we’re now able to do stuff like “long-play Sunday”, where all of the tracks are long-players of a half-hour or more.
But there were some hitches, mostly in my stuff: the iOS app and the now-playing Discord bot. Because of reasons (read: I’m not sure why), the Icecast metadata isn’t available from the streaming server on Azuracast, especially when you’re using TLS. This breaks the display of artist and track on the iOS app, and partially breaks the icecast-monitor
Node library I was using to do the now-playing bot in Discord.
(Side note: this was all my bright idea, and I should have tested the app and bot against Azuracast before I proposed cutting over in production, but I didn’t. I’ll run any new thing in Docker first and test it better next time.)
Azuracast to the rescue
Fortunately, Azuracast provides excellent now-playing APIs. There a straight-up GET
endpoint that returns the data, and two event-driven ones (websockets and SSE). The GET
option depends on you polling the server for updates, and I didn’t like that on principle; the server is quite powerful, but I don’t want multiple copies of the app hammering it frequently to get updates, and it was inherently not going to be close to a real-time update unless I really did hammer the server.
So that was off the table, leaving websockets and SSE, neither of which I had ever used. Woo, learning experience. I initially tried SSE in Node and didn’t have a lot of success with it, so I decided to go with websockets and see how that went.
Pretty well actually! I was able to get a websocket client running pretty easily, so I decided to try it that way. After some conferring with ChatGPT, I put together a library that would let me start up a websocket client and run happily, waiting for updates to come in and updating the UI as I went. (I’ll talk about the adventures of parsing Azuracast metadata JSON in another post.)
I chose to use a technique that I found in the FRadioPlayer source code, of declaring a public static variable containing an instance of the class; this let me do
import Kingfisher
import ACWebSocketClient
client = ACWebSocketClient.shared
...
tracklabel.text = client.status.track
artistlabel.text = client.status.artist
coverImageView.kf.getImage(with:client.status.artURL)
(Kingfisher is fantastic! Coupled with Azuracast automatically extracting the artwork from tracks and providing a URL to it, showing the right covers was trivial. FRadioPlayer uses the Apple Music cover art API to get covers, and given the, shall we say, obscure artists we play, some of the cover guesses it made were pretty funny.)
Right. So we have metadata! Fantastic. Unfortunately, the websocket client uses URLSessionWebSocketTask
to manage the connection, and that class has extremely poor error handling. It’s next to impossible to detect that you’ve lost the connection or re-establish it. So It would work for a while, and then a disconnect would happen, and the metadata would stop updating.
Back to the drawing board. Maybe SSE will work better in Swift? I’ve written one client, maybe I can leverage the code. And yes, I could. After some searching on GitHub and trying a couple of different things, I created a new library that could do Azuracast SSE. (Thank you to LaunchDarkly and LDSwiftEventSource
for making the basic implementation dead easy.)
So close, but so far
Unfortunately, I now hit iOS architecture issues.
iOS really, really does not want you to run long-term background tasks, especially with the screen locked. When the screen was unlocked, the metadata updates went okay, but as soon as the screen locked, iOS started a 30-second “and what do you think you’re doing” timer, and killed the metadata monitor process.
I tried a number of gyrations to keep it running and schedule and reschedule a background thread, but if I let it run continuously, even with all the “please just let this run, I swear I know what I need here” code, iOS would axe it within a minute or so.
So I’ve fallen back to a solution not a lot better than polling the endpoint: when the audio starts, I start up the SSE client, and then shut it down in 3 seconds, wait 15 seconds, and then run it again. When audio stops, I shut it off and leave it off. This has so far kept iOS from nuking the app, but again, I’m polling. Yuck.
However, we now do have metadata, and that’s better than none.
On the other hand…
On the Discord front, however, I was much more successful. I tried SSE in Node, and found the libraries wanting, so I switched over to Python and was able to use sseclient
to do the heavy lifting for the SSE connection. It essentially takes an SSE URL, hooks up to the server, and then calls a callback whenever an event arrives. That was straightforward enough, and I boned up on my Python for traversing arbitrary structures — json.loads()
did a nice job for me of turning the complicated JSON into nested Python data structures.
The only hard bit was persuading Python to turn the JSON struct I needed to send into a proper query parameter. Eventually this worked:
subs = {
"subs": {
f"station:{shortcode}": {"recover": True}
}
}
json_subs = json.dumps(subs, separators=(',', ':'))
json_subs = json_subs.replace("True", "true").replace("False", "false")
encoded_query = urllib.parse.quote(json_subs)
I pretty quickly got the events arriving and parsed, and I was able to dump out the metadata in a print. Fab! I must almost be done!
But no. I did have to learn yet another new thing: nonlocal
in Python.
Once I’d gotten the event and parsed it and stashed the data in an object, I needed to be able to do something with it, and the easiest way to do that was set up another callback mechanism. That looked something like this:
client = build_sse_client(server, shortcode)
run(client, send_embed_with_image)
The send_embed_with_image
callback puts together a Discord embed (a fancy message) and posts it to our Discord via a webhook, so I don’t have to write any async code. The SSE client updates every fifteen seconds or so, but I don’t want to just spam the channel with the updates; I want to compare the new update to the last one, and not post if the track hasn’t changed.
I added a method to the metadata object to compare two objects:
def __eq__(self, other) -> bool:
if not isinstance(other, NowPlayingResponse):
return False
if other is None:
return False
return (self.dj == other.dj and
self.artist == other.artist and
self.track == other.track and
self.album == other.album)
…but I ran into a difficulty trying to store the old object: the async callback from my sseclient
callback couldn’t see the variables in the main script. I knew I’d need a closure to put them in the function’s scope, and I was able to write that fairly easily after a little poking about, but even with them there, the inner function I was returning still couldn’t see the closed-over variables.
The fix was something I’d never heard of before in Python: nonlocal
.
def wrapper(startup, last_response):
def sender(response: NowPlayingResponse):
nonlocal startup, last_response
if response == last_response:
return
# Prepare the embed data
local_tz = get_localzone()
start = response.start.replace(tzinfo=local_tz)
embed_data = {
"title": f"{response.track}",
"description": f"from _{response.album}_ by {response.artist} ({response.duration})",
"timestamp": start,
"thumbnail_url": response.artURL,
}
# Send to webhook
send_webhook(embed_data)
startup = False
last_response = response
return sender
Normally, all I’d need to do would be have startup
and last_response
in the outer function’s argument list to have them visible to the inner function’s namespace, but I didn’t want them to just be visible: I wanted them to be mutable. Adding the nonlocal
declaration of those variables does that. (If you want to learn more about nonlocal
, this is a good tutorial.)
The Discord monitor main code now looks like this:
startup = True
last_response = None
# Build the SSE client
client = build_sse_client(server, shortcode)
# Create the sender function and start listening
send_embed_with_image = wrapper(startup, last_response)
run(client, send_embed_with_image)
Now send_embed_with_image
will successfully be able to check for changes and only send a new embed when there is one.
One last notable thing here: Discord sets the timestamp of the embed relative to the timezone of the Discord user. If a timezone is supplied, then Discord does the necessary computations to figure out what the local time is for the supplied timestamp. If no zone info is there, then it assumes UTC, which can lead to funny-looking timesstamps. This code finds the timezone where the monitor code is running, and sets the timestamp to that.
from tzlocal import get_localzone
local_tz = get_localzone()
start = response.start.replace(tzinfo=local_tz)
And now we get nice-looking now-playing info in Discord:
Building on this
Now that we have a working Python monitor, we can now come up with a better solution to (close to) real-time updates for the iOS app.
Instead of running the monitor itself, the app will register with the Python monitor for silent push updates. This lets us offload the CPU (and battery) intensive operations to the Python code, and only do something when the notification is pushed to the app.
But that’s code for next week; this week I need to get the iOS stopgap app out, and get the Python server dockerized.
Leave a Reply
You must be logged in to post a comment.