An Ill-Advised Project #1
I am easily influenced, as much as I will always want to claim otherwise. One such occasion was when I saw Instagram user @mewtru’s reel where they had created an LED matrix that displayed spinning album art from Spotify. To get an idea of how targeted to my interests this is, I saw it independently and then had two people I know show me the reel: it’s music, flashing lights and computers and I am hideously typecast in life.
I’ve recently been dealing with a newly diagnosed (but, if I’m being honest with myself, long suspected) illness, with an acute summer viral infection on top and I’ve been looking for distraction from the fact that I can’t eat solid food for three months. What better time to throw myself into something that has no real practical application but will soak up time and mental effort from thinking about just how many unethical things I would do for a pizza?
But there’s so much about the original that just doesn’t sit well with me and my preferences. First of all, we have the reliance upon Spotify for the source of both music and album art: I’m not a Spotify user on account of wanting own my media in general and their pathetic, even among terrible competitors payouts for artists. Given the diversity of the ways that I listen to music (local MP3s/FLAC; Amazon Music; other streaming sources), making use of the fact that I obsessively log everything to ListenBrainz makes sense, as well as relying on MusicBrainz for canonicalisation of metadata differing depending on the source of the music. With those datasources there somewhat by default, the use of the Cover Art Archive for the cover art itself was somewhat of a gimme: if I have the data from MusicBrainz anyway, why not use a datasource for cover art that is indexed on that same MusicBrainz data?
Second, while I appreciate the appeal of a dynamic visual, I’m content (for now, at least) with just being able to visualise the album art for whatever it is that I’m listening to. A static image will more than do for me and won’t require any image processing on the thing driving the RGB matrix. Given that the hardware that will be driving the matrix won’t be too capable, I’d like to keep it simple for now.
There’s also the aspect of the hardware used: the referenced reel sees the use of a Raspberry Pi Zero W/2 W. I don’t know what they did to obtain one, but you can’t manage it for love nor money at the moment. Well, money is probably going to do the job, but I don’t particularly value the idea of spending £30+ for something that should retail at half that. With that in mind, I thought I’d try getting the readily available Pi Pico 2 W. The limited memory and processing power of the Pico would restrict it to just being responsible for handling putting the data that we’d get and manipulate from somewhere else onto the 64x64 RGB matrix I’d picked out for display. The Pico W series have all of the GPIO and network hardware that we should need to perform that task. The design in my head ended up something like the below:

PlantUML source
@startuml
interface "RGB Matrix\nvia GPIO" as RGB
RGB - [Pi Pico 2W]
[Pi Pico 2W] ..> TCP : use
[Server Running\nAlbum Art application] - TCP
[Server Running\nAlbum Art application] ..> HTTPS : use
[ListenBrainz] - HTTPS
[MusicBrainz] -- HTTPS
[Cover Art Archive] -- HTTPS
@enduml
Naming, one of the Two Hard Things#
For the first time in my life, names just came to me as I looked over what I’d brought home from Fopp on a given day. Between Phoebe Bridgers’s Stranger in the Alps and Sabrina Carpenter’s Short n’ Sweet, the combination of smoke-signals for the part that goes and gets information (signals, if you will) from various services to put together a message for the microcontroller and make-an-impression for the bit that handles the lights and makes an impression on the world, with light, felt right.
Fuck off, I entertain myself and that’s all that really matters here. There were also copies of PJ Harvey’s Rid of Me and Hole’s Live Through This in that pile, so the connections could have been even more torturous had I so decided.
The first cut of smoke-signals#
One of the benefits of the approach that I’d landed on was that I wasn’t dependent on any hardware to get started on the half of the project that would be the process of identifying what I was listening to, what the corresponding MusicBrainz release ID (mbid) was and looking up the mbid in the Cover Art Archive and then retrieving the album art. From there, we could resize the image to the 64x64 required by the matrix and dump the image data to some data format that we could then display on the matrix. We’d also have to run a server to, well, serve that information to the Pi Pico-based client.
I had a decision to make about the tech stack that I’d use to make this, and it got made pretty easily: there’s a very obvious need for concurrency in this project between the getting and serving data aspects of smoke-signals, I like that Rust has Ferris the crab and I’ve not done an awful lot of concurrent Rust in the async style. That, coupled with me not really knowing where I’d end up running the server and the general strength in the Rust ecosystem for cross-compilation between processor architectures, made the decision all the more simple. We’d be using Rust with the Tokio async runtime.
So what are we building?#
- A process that will poll ListenBrainz to see what I’m listening to, fetch album art from an album art provider, resize it and put it in a format we can consume on a microcontroller
- An async server loop to handle incoming connections from the microcontroller
- An async polling loop to see when the currently playing song has changed, and on change go and fetch the new metadata and album art
- Some means of getting data between these two processes
- A TCP server for a client to connect to receive the data obtained in (1) and drive the RGB matrix
Hurdles learning async (my brain, mainly)#
Anyone who has ever known me, and especially those who have worked with me, are probably aware that I am terrible at reasoning about time. Given that async programming is pretty much all about thing x doing y at t1 until t3, and allowing to do thing a to b until t2 while we wait for x to finish, I am not mentally built for this. My lack of ability to think about time reared its head in the first iteration of the loop for handling TCP connections.
async fn server() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
loop {
let (socket, _) = listener.accept().await.unwrap();
handle_conn(socket).await.unwrap();
}
}
async fn handle_conn(mut socket: TcpStream) {
socket.write_all("test".as_bytes()).await.unwrap();
}
#[tokio::main]
pub async fn main() {
let server = tokio::spawn(server());
let album_art = tokio::spawn(start_album_art_loop());
join!(server, album_art);
}
If you can’t spot what’s wrong with this, you’re no better than me: please, self-flagellate. We spawn a two tasks: one for the loop that will go and fetch music metadata and one for the task to handle incoming TCP connections. In that server task, for every connection that comes in we call handle_conn and then await the socket write. If we have a number of connections coming in, we’re going to experience delays in getting connections handled at best, an inability to connect at worst. Fortunately, doing a little better is quite easy with Tokio’s predicates here:
async fn server() {
let listener = TcpListener::bind("127.0.0.1:8080").await.unwrap();
loop {
let (socket, _) = listener.accept().await.unwrap();
// spawn a task for every new connection! Actually achieve concurrency!
tokio::spawn(handle_conn(socket));
}
}
async fn handle_conn(mut socket: TcpStream) {
info!("Connection accepted!");
socket.write_all("test".as_bytes()).await.unwrap();
}
For a toy project where we’re going to have (short term, at least), just one client, this probably doesn’t matter at all. But for the code golf-esque satisfaction of being able to say that the numbers coming out of my completely arbitrary performance tests are higher than they would be otherwise, it’s definitely a worthwhile pursuit.
Be explicit about what is blocking work#
The only real bit of computation that we do in this flow is resizing the album art image that we retrieve, rotating it and converting the image data into a Vec<u8> array of bytes. This can be computationally expensive and the image crate I used for this is not async: if we ran this in Tokio’s normal worker threads, we’d have worker threads being blocked as these functions will never call await and will therefore never yield control of the thread to the scheduler (more detail on this in the Tokio docs). Instead, this code gets run in a blocking thread, spawned on demand, allowing the pool of worker threads to continue to be scheduled and have tasks swapped in and out of processing by the Tokio runtime.
tokio::task::spawn_blocking(move || {
let img = ImageReader::new(Cursor::new(bytes.as_ref()))
.with_guessed_format()?
.decode()?;
// we have to rotate and flip for the output to come out right on the Matrix - which is Column-Major
let resized_image = img
.resize_to_fill(64, 64, image::imageops::FilterType::Gaussian)
.rotate90()
.flipv();
let rgb_values = resized_image.to_rgb8().into_raw_bgr().clone();
Ok(rgb_values)
})
Why are we manipulating the images the way that we are?#
// We have to rotate and flip for the output to come out right on the Matrix - which is Column-Major
let resized_image = img
.resize_to_fill(64, 64, image::imageops::FilterType::Gaussian)
.rotate90()
.flipv();
let rgb_values = resized_image.to_rgb8().into_raw_bgr().clone();
As part of the image resizing, we rotate the image 90 degrees and flip it vertically. If we don’t do this, we get a Vec<u8> of RGB values that is a representation of the image with the origin at the top left of the image, with each three bytes representing the next pixel to the right of the first until we reach the nth pixel, where that pixel is the width of the image. The next three bytes will represent the first pixel on the left on the second row of the image, and so forth. As the RGB matrix I’m using for this project is column-major (as opposed to most image formats’ row-major encoding), we get a rotated and flipped image on the matrix if we don’t do this.
Data formats for exchange with make-an-impression or look, not everything needs to be fucking JSON#
I am, as a necessity of the technological landscape I’ve spent most of my professional life in, unfortunately webdev-brained, and webdev-brained in a really Twelve-Factor App, RESTful-architecture-returning-JSON-from-endpoints-and-sometimes-XML-have-I-ever-mentioned-I-was-an-enterprise-architect kind of way. I’d started smoke-signals with that mindset, and the AlbumData struct that I’d initially written with the view of sending data to the client looked like the below, with me wanting to send the artist, recording and release data with a representation of the RGB values for each pixel of the 64x64 version of the album art that we create in the application (so three bytes for each pixel in a 64x64 grid, giving us exactly 12,288 bytes to ship):
#[derive(Serialize, Debug, Clone)]
struct AlbumData {
artist: String,
recording: String,
release: String,
art: Arc<[u8]>,
#[serde(skip_serializing)]
hash: String,
}
Rust’s serde crate is intuitive to use and provides derive macros for serialisation and deserialisation of structs into any number of data formats: it’s very convenient, and I’d already used it to marshal JSON from the endpoints I was calling into the structs I had created to map those data structures. I could get my struct converted into JSON for ‘free’, so why not?
The ‘good enough’ reason is that sending what is essentially an array of bytes as a JSON array of ints is wasteful and adds a decoding overhead at the receiving side that just isn’t necessary. If I just have a tiny Pi Pico on the other side, why do I need to implement JSON decoding on it just to get a couple of strings and about 12kB of data out of a data structure? Given the length of the array that we’d have, we’d also be wasting 12,287 bytes in the transfer with commas alone.
I’ve had some experience in the past of dealing with the Garmin FIT Protocol and EXIF segments in JPEGs, and I was reminded of the idea (long, long storied in computing, I’m aware) of just having a simple binary encoding that tells the recipient how long the message it should expect to process is, how long each field is and the content of that field should be sufficient to get us started. This should look a little different to our AlbumData struct: we need to specify the length of the message and its constituent parts. We end up with something like:
| Data item | Length | Purpose |
|---|---|---|
| Total Message Length | 16-bits/2-bytes | Tells the client the full length of one message |
| Artist length | 8-bits/1-byte | Tells the client the length of the artist field |
| Artist field | n-bytes | Tells the client the artist name |
| Release length | 8-bits/1-byte | Tells the client the length of the album field |
| Release field | n-bytes | Tells the client the album name |
| Album art length | 16-bits/2-bytes | Tells the client the length of the album art field |
| Album art field | n-bytes | It’s the album art in RGB format |
And the implementation:
pub struct AlbumData {
// we ended up trimming some of the data fields from v0.0.0.1 as they weren't used
pub artist: String,
pub release: String,
pub art: Arc<[u8]>,
}
pub struct MessageData {
pub total_len: u16,
pub artist_len: u8,
pub artist: String,
pub release_len: u8,
pub release: String,
pub art_len: u16,
pub art: Arc<[u8]>,
}
impl From<AlbumData> for MessageData {
fn from(album_data: AlbumData) -> MessageData {
let artist_len = album_data.artist.len() as u8;
let artist: String = album_data.artist;
let release_len = album_data.release.len() as u8;
let release = album_data.release;
let art_len: u16 = album_data.art.len() as u16;
let total_len = (2 * size_of::<u16>() as u16)
+ (2 * size_of::<u8>() as u16)
+ art_len
+ artist_len as u16
+ release_len as u16;
MessageData {
total_len,
artist_len,
artist,
release_len,
release,
art_len,
art: album_data.art,
}
}
}
impl From<MessageData> for Vec<u8> {
fn from(md: MessageData) -> Vec<u8> {
let mut ret_vec: Vec<u8> = Vec::with_capacity(md.total_len as usize);
// it's customary to send bytes over the network big-endian irrespective of recipient endianness
let total_len = md.total_len.to_be_bytes();
ret_vec.extend_from_slice(&total_len);
ret_vec.push(md.artist_len);
ret_vec.extend_from_slice(md.artist.as_bytes());
ret_vec.push(md.release_len);
ret_vec.extend_from_slice(md.release.as_bytes());
ret_vec.extend_from_slice(&md.art_len.to_be_bytes());
ret_vec.extend_from_slice(&md.art);
ret_vec
}
}
Are there problems with this? Yes. If post-rock comes back into vogue and we end up with people taking up Godspeed You! Black Emperor’s penchant for long album titles, one byte might not be enough to send the whole title in the message. But for now Lift Your Skinny Fists Like Antennas To Heaven is only 46 characters and we’re safe. I’m choosing to ignore, for now, the horror of multi-byte unicode characters because I only have one life. For my purposes as an idiot scratching his own itches, we can live with this. I’ve mentioned a couple of times that I know that the size of the album art will always be 12,288 bytes: then why am I sending the length? Future proofing in case I get a 128x128 RGB matrix, I told myself, before doing the maths and realising that 49,152 is also outside of the bounds of a 16-bit integer. Maybe I’ll get something smaller.
The From trait in Rust’s std::convert crate is pretty cool: by only defining a From method for a struct, you get a way to convert between two types both ways because of how the generic Into trait is implemented in the standard library. It’s not particularly relevant here, as we’re just emitting this data and not looking to receive it, but it’s a neat feature.
Least Recently Used caching, rather than caching everything forever#
I’d gone into this project either 1) not thinking too much about the strictly best ways to do a lot of things and 2) not capable of much thought as I was having a simply great time with my meat prison. I had, however, had the good idea of caching the album art once I’d fetched it to speed up the display of album art on the RGB matrix by bypassing the serial network calls required to go from Now Playing message retrieved from ListenBrainz to an album art download. I had not, however, thought about the fact that using just a bare HashMap would eventually - if the process were to run for long enough - lead to me having a potentially huge hashmap full of binary image data. Imagine if I got to the point that my 128GB of RAM on my desktop was completely full with album art, 12kB at a time? A hellish thought.
So we go from:
let mut cache: HashMap<String, AlbumData> = HashMap::new();
To, with the help of the lru crate:
let mut cache: LruCache<String, AlbumData> = LruCache::new(NonZeroUsize::new(100).unwrap());
We now have a bound of 100 entries on the in-memory cache - we shouldn’t ever (hopefully) get ourselves into a situation where we’re just pissing away memory as we’re absent-mindedly listening to Amazon’s Music awful autoplay playlists, while benefitting from our favourite intentional plays being retained in the cache so long as one out of one-hundred albums played is one of our faves.
We have hashing algorithms, why not use those as a cache key?#
async fn get_cache_key(metadata: &impl MetadataProvider) -> String {
// We only really care about 'releases' from the point of view of album art - no need for get_recording_name
hex::encode(format!(
"{}{}{}",
metadata.get_artist_name(),
CACHE_ENTRY_SEPARATOR,
metadata.get_release_name(),
))
}
I’m not sure if this is elegant or stupid: if we have an LRU cache, I also want to be able to repopulate the cache from local disk if we have an album that we’ve played before (ever, really) that we’ve already gotten the album art for. If I used a hash of the artist name and album name (or whatever inputs I wanted), I wouldn’t be able to retrieve the artist name and the album name from the cache. Given that the cache is based on the contents of the AlbumData struct, I need to be able to work back from my cache key to the input content: I can’t do that with a hash, unless I had some sort of datastore that was mapping hashes to the metadata those hashes were being generated from. This also gives us the benefit of getting known-filesystem safe paths that we can store the cache files to through the hex encoding of the text that forms the cache key’s components.
How do we handle the song changing?#
So we have an async task going off and doing some TCP server activities and we have an async task going off and fetching music metadata and album art. How do we make them talk to each other to notify the server when things have changed? Tokio’s watch channel is a neat way to do this: it holds a single value and notifies subscribers when that value changes. The metadata look holds the Sender for the channel and every connection handling tasks get a clone of the Receiver. Every connection held open is able to wait for a new value to be announced until it completes one iteration of the connection loop.
async fn connection_loop(
socket: &mut TcpStream,
rx: &mut Receiver<AlbumData>,
) -> Result<(), ServerError> {
let album_data = rx.borrow_and_update().clone();
let message_data = MessageData::from(album_data);
socket.write_all(Vec::from(message_data).as_slice()).await?;
rx.changed().await?; // Suspends cleanly until a new track arrives
Ok(())
This also has the benefit of ensuring that the connection is held open (as it will not be dropped by Rust as long as we are awaiting the Reciever - the TcpStream will not go out of scope and be de-allocated), lowering the overhead for our client of having to establish new connections every time that it wants to poll for a song change.
How can we make this better?#
Looking at serde again#
It would be nicer, I think, and maybe more idiomatic Rust, to implement my conversion of the AlbumData struct into the MessageData struct that pretty much defines the smoke-signals protocol using serde. There’s probably no real benefit to doing that here but again: maybe something to do for the electric thrill of learning something new. The conversion of MessageData into a Vec<u8> (vector of bytes) only to convert it to a slice when we pass that representation to write_all on the TcpStream in the connection loop feels inelegant.
async fn connection_loop(
socket: &mut TcpStream,
rx: &mut Receiver<AlbumData>,
) -> Result<(), ServerError> {
let album_data = rx.borrow_and_update().clone();
let message_data = MessageData::from(album_data);
// perhaps if we use `serde` here, we don't have to
socket.write_all(Vec::from(message_data).as_slice()).await?;
rx.changed().await?;
Ok(())
}
Although, writing this, a From implementation for [u8] from message_data may also be just as good, and being able to write just message_data.into() as the argument to write_all there would feel so much cleaner.
Logging and tracing#
I used the Tokio tracing crate to get some logging information to print while I was developing this and I was… profligate both with what was logged and at what logging level. I doubt there is any logging here that is actionable in any way, and certainly there is a lot of noise. Definite space to improve here.
Tests!#
It would be good to know when I’ve broken something with a change, and I do love a good unit test. This is something I should look at for the future, definitely.
More Cover Art providers#
Using the ListenBrainz lookup endpoint, I will get an mbid for a given track, more than likely. I am not, however, guaranteed to get an mbid for a release that has album art on Cover Art Archive. Either I need to be more thorough with how I get information from MusicBrainz (and get all possible mbids) for a given release name and query them all from the Cover Art Archive or I need to find more metadata providers that I can reliably call with metadata and get the right album art.
Caching improvements#
There’s a couple of silly things that we do around caching at the moment. First is attempting to repopulate the LRU cache from disk on startup: if we have more than 100 cached images on disk, we do the transformation work on the album art for nothing as whatever number above 100 images we have will be evicted from the cache as part of the startup process. We should probably just check the disk as part of deciding whether we need to go to the network to get album art instead.
It’s probably also worth reconsidering what it is that needs to be cached and stored on disk: at the moment, we store the source image from Cover Art Archive. Maybe it would be more expedient to only cache the bytes representation of the album art without its metadata - in this case, a hash of the metadata would be sufficient to identify it as I wouldn’t need to hydrate the full AlbumArt struct from disk, just provide the hash-as-filename and content of the file.
The first publishable-without-shame version#
What this would end up becoming can be found on GitHub. I think, for a first cut, it’s not that horrific.
The first roadblock#
I live in the middle of a city and my parcel containing the Pico and the LED matrix was left in a “safe place” by Royal Mail. That’s to say it was left on my doorstep and someone helped themselves to it. I hope whoever picked it up enjoys it very much and lives a long and healthy life. Fortunately, the Pi Hut were very good about this and got a second parcel out to me.
The second roadblock#
The Pi Pico 2 was probably not the right hardware to choose in the first place. I’d found individual code examples for the lwIP stack that the Pico uses and an example driver for the HUB75-based RGB matrix that I’d bought. I had not, however, seen any evidence of these two things in use at the same time.
Following a little tinkering, I found that the code that I’d written (pure C) to handle the network stack and connect to smoke-signals just didn’t work when compiled with a C++ compiler. The driver that I had found relied on C++-only language features to drive the matrix. After a little fiddling and a little DuckDuckGo-ing, I also found that the interrupts used by the matrix driver and the wireless components of the Pico SDK didn’t play well together: we’ll put this down to a failure of research on my part and just my flighty nature and unhealthy thought that I will always be able to make something work somehow.
Between the first roadblock and this one, I just wanted to get something working that would make me feel like I’d accomplished something in a week of being sick and hungry: just something to remind myself that life is more than blowing my nose and listening to my stomach growl. My youthful profligacy, had led to me having a spare Raspberry Pi 4B knocking around, which I figured would have the benefits of an actual operating system over the microcontroller approach and hopefully sufficient software support to mean I wouldn’t have to think too much in my groggy state.
Suitably chastened by my foray into lower-level C and C++ programming and my abject failures there, I thought I’d go and write something that would just get me working as quickly as possible. A Python script that would make (an impression, see) it as easy as possible to prove that I hadn’t been labouring under any more design misapprehensions felt like a good idea.
A naive make-an-impression implementation#
We can’t escape the need for C/C++ compilation#
The vendor for the RGB matrix handily included some instructions for how to build the supporting library in a broadly sane way using virtualenvs. As with any C FFI library, there was a need to install some C dependencies to allow things to build, and with the stable and shared base of Raspberry Pi OS, I didn’t expect anything to be too difficult: I’d install the right things from apt and I’d be off to the races:
apt install -y git build-essential cmake \
pkg-config \
python3-full python3-dev python3-venv \
python3-pip \
cython3 libjpeg-dev zlib1g-dev
All makes sense: build tools, Python library headers, a JPEG library, a compression library. All that I’m told I’ve left to do is run python -m pip install numpy pillow scikit-build-core cython and python -m pip install -v . and I should be off to the races.
No, we missed a dependency in our documentation: I see the error we can’t import Imaging.h from the Pillow shim used by the library. A quick install of apt-file and an apt-file update later and I managed to find that that binding was in the package python3-pil with an invocation of apt-file search Imaging.h. With that installed, we managed to build the library.
The initial version#
I think it’s a lot rougher than the server half of this, but I believe in showing ugly things off in the hope that I get to improve them.
How does this perform?#
Well, it works, and what’s more important than that? Production-safe? Sure, it’s a thing that sits on my desk: I can produce a process restart whenever I want. The keen-eyed among readers may have noticed that we, for every loop, create a connection and then close it. This isn’t in line with the effort I went to with smoke-signals to ensure that the server would be able to notify the client immediately once a new song has been detected as playing from ListenBrainz.
Receive exactly the number of bytes you expect#
def recv_exact(sock, n):
data = bytearray()
while len(data) < n:
packet = sock.recv(n - len(data))
if not packet:
raise ConnectionError("Connection closed.")
data.extend(packet)
return data
This is a useful helper function: you don’t always get the number of bytes you request when you call recv on a socket: you get up to that amount depending what’s in the OS’s network buffer at the time of the call. If we keep on trying to recv until we get the count of bytes we expect, we actually get what we ask for.
We’ll ignore the fact that I only used it for the album art element of the message: I deserve faults that I can’t diagnose because of network issues that may well be transient. If I can’t recv() 2 bytes for the message length because of the network buffer not having two bytes in it and I get the wrong value because of that, I simply have to accept the universe is out to get me.
Future improvements#
make-an-impression, as it stands, is incredibly brittle and I’m not sure I have faith that I’d be able to rebuild the Python driver for the matrix if the vendor documentation website disappeared. This half of the project was in no way an exercise in engineering, but more an exercise in getting something that shows me pretty lights as quickly as possible.
Similar concurrency optimisations to smoke-signals#
In my mental model for the delivery of the Pico version of this solution, one of the processor cores would have been responsible for the handling of network traffic and the other would have been responsible for the handling of managing the matrix. As it stands, we just do a big dump loop in one thread calling the server with a new connection every time, driving the matrix in every loop. This is inefficient in that we’re calling updates to the matrix more often than we need to.
Better error handling#
Frankly, there is no error handling at the moment. Some would be better than none.
Where things stand (for now)#
Despite stolen deliveries, low-level interrupt mismatches, and a pivot from C on a Pi Pico 2 W to Python on a Raspberry Pi 4, I have what I set out for: pretty flashing lights on my desk showing the album art of whatever I’m currently scrobbling to ListenBrainz. Most of the time.
It’s far from polished. smoke-signals has logging that reads like a cry for help from someone who is really fixated on the number 12288, make-an-impression is held together by good vibes and I still can’t eat solid food for another two months. But as a purely ill-advised, zero-practical-utility distraction? It’s been an absolute success.
In Part #2, I’ll be back to tackle fixing the client-side TCP connection handling, properly handling missing cover art across multiple providers, and maybe going a little bit closer to the metal on the client side. I’m still somewhat chastened though.