Sonos Album Art

I did some searching and saw that there is a way to get album art from Echo speaks but didn't find anything current.

Is anyone able to pull current album art from sonos? I am not opposed to running something outside of hubitat and using an image tile if need be.

Poking around in the Sonos node server I am running it seems like there is a URL for album art but it isn't refreshing when I change the song.

How are you poking around on the Sonos server (I assume it's the node-sonos-http-api)? If it's in the browser, are you refreshing the page when the song changes?

I found that I could get a URL to the album art for current song and next song via http://<server ip>:5005/zones. Specifically, there are <zone number>:coordinator:state:currentTrack:absoluteAlbumArtUri and <...>:nextTrack:absoluteAlbumArtUri entries.

Are you willing to pull these out and populate the values using a custom app or driver? You'd just need to GET the zones URI and then parse out the values. This may be possible in RM, but I haven't really done any parsing with web requests there.

yeah using the browser and hitting the State URL.

an app that would control Sonos through node-sonos-http api would be great. It would expose some things like grouping that aren't available now.

I use the node-sonos-http-api in conjunction with the built-in Hubitat functionality. They co-exist reasonably well.

I didn't take the time to integrate it into a whole app/driver, but you can use a custom driver like this Hubitat example to make GET requests to the node server.

I modified that example to be a button instead of a switch, since it's more intuitive and useful to me to use button pushes instead of having to manage switch state.

You can generate a bunch of instances of buttons to do various things, or you could map button numbers internally to specific URIs for requests. I use it for grouping primarily. You could also use it to GET and parse out album art URLs and assign them to driver attributes for display on a dashboard.

Parsing out the Image information is to much for me but currently I have a virtual button with 3 buttons. I then map those buttons to a rule in RuleMachine for next/previous and play pause.

I am wondering if I could map those actions in the virtual device?

for me this is the only thing missing from my HE dashboards--as @tomw says above the information is there in the states. I am going to poke around and see how to make this happen. not a dev but a tinkerer...I notice that SharpTools makes a call to Music Brains like this https://musicbrainz.org/ws/2/release/?query="Best%20of%20Coldplay" AND artist:"Coldplay"&fmt=json and then pulls the image from lastfm https://lastfm.freetls.fastly.net/i/u/300x300/fc0082e5ba9f4c9e9ee2bc46e9a9a089.png

In my experience, you can get the album art from the Sonos device without having to go to a third service like Musicbrainz. That is, if the track has art associated with it or embedded in it. I have tested with Spotify (works) and my own ripped and tagged files (also works most of the time).

Here's my driver for doing this. I run the node-sonos-api on a RPi and also use the webhook functionality to cause refreshes by Hubitat on change from the server. You just have to set the virtual device DNI to the MAC address of the node-sonos-api server. Otherwise, you can just issue refresh commands to the driver.

This is a little bit rough around the edges and doesn't scale well to multiple speakers. If you like it and there is interest in using it system-wide, I could clean it up and release it for real.

/*


 */
metadata
{
    definition(name: "Sonos Buttons", namespace: "tomw", author: "tomw", importUrl: "")
    {
        capability "Configuration"
        capability "PushableButton"
        capability "Refresh"
        command "push", ["number"]
        
        command "addButton", ["number", "command"]
        command "deleteButton", ["number"]
        command "clearButtons"
        
        command "join", ["master"]
        command "leave"
        
        attribute "albumArtUri", "string"
        attribute "albumArtDisplay", "string"
    }
}

preferences
{
    section
    {
        input "sonosName", "text", title: "Speaker name", required: true
        input "sonosIP", "text", title: "Speaker IP", required: true
        input "nodeIP", "text", title: "node-sonos-http-api server IP", required: true
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
    }
}

def logDebug(msg) 
{
    if (logEnable)
    {
        log.debug(msg)
    }
}

def updated()
{
    configure()
}

def configure()
{
    state.clear
    clearButtons()
}

def refresh()
{
    def art = findArtByName()
    sendEvent(name: "albumArtUri", value: art ? art : "unknown")
    sendEvent(name: "albumArtDisplay", value: art ? "<img src=\"${art}\">" : "unknown")
}

def push()
{
    // do nothing
}

def push(buttonNumber)
{    
    logDebug("Http GET Button: push(${buttonNumber})")
    sendEvent(name: "pushed", value: buttonNumber, isStateChange: true)
    
    def command = ""
    if(state.buttonMap.containsKey(buttonNumber.toString()))
    {
        // use user-defined button, if it exists
        command = state.buttonMap[buttonNumber.toString()]
    }
    else
    {
        // enter pre-defined buttons here,
        //   if they're not defined by the user
        switch(buttonNumber)
        {
            case 1:
                command = "pause"
                break
            case 2:
                command = "play"
                break
            case 3:
                join("Kitchen")
                break
            case 4:
                leave()
                break
        }
    }
    
    if("" != command)
    {
        httpGetExec(nodeIP, "${sonosName}/${command}", 5005)
    }
    else
    {
        logDebug("unknown button command")
    }
}

def addButton(number, command)
{
    if(!number.isInteger())
    {
        log.debug "addButton() failed: number must be integer"
        return
    }
    
    state.buttonMap[number] = command    
}

def deleteButton(number)
{
    state.buttonMap.remove(number.toString())
}

def clearButtons()
{
    state.buttonMap = [:]
}

def join(master)
{
    httpGetExec(nodeIP, "${sonosName}/join/${master}", 5005)    
}

def leave()
{
    httpGetExec(nodeIP, "${sonosName}/leave", 5005)    
}

def parse(resp)
{
    logDebug("parseLanMessage(resp) = ${parseLanMessage(resp)}")
    
    // just refresh() on any change
    refresh()
}

def findArtByName()
{
    def resp = httpGetExec(nodeIP, "zones", 5005) 
    
    for(zone in resp)
    {
        // look at top level first...
        if(sonosName == zone?.coordinator?.roomName)
        {
            // assume that all members have the same art, so just take the first one
            return fixupUri(zone?.members[0]?.state?.currentTrack?.absoluteAlbumArtUri)
        }
        
        // ...look within zone if not found
        for(member in zone?.members)
        {
            if(sonosName == member?.roomName)
            {
                return fixupUri(member?.state?.currentTrack?.absoluteAlbumArtUri)
            }
        }                
    }
    
    // return null if we didn't find it
    return
}

def setupFromApp(sonosName, sonosIP, nodeIP)
{
    device.updateSetting("sonosName", sonosName.toString())
    device.updateSetting("sonosIP", sonosIP.toString())
    device.updateSetting("nodeIP", nodeIP.toString())
}

def refreshFromApp()
{
    refresh()
}

def fixupUri(origUri)
{
    // for internal library files, check for and prepend absolute path
    return (origUri.contains("http") ? origUri : "http://${sonosIP}:1400${origUri}")
}

def getBaseURI(ipAddr, port = null)
{
    def newURI = "http://" + ipAddr
        
    return newURI + (port ? ":${port}/" : "/")
}

def httpGetExec(ipAddr, suffix, port = null)
{
    logDebug("httpGetExec(${ipAddr}, ${suffix}, ${port})")
    
    try
    {
        getString = getBaseURI(ipAddr, port) + suffix
        def result
        httpGet(getString.replaceAll(' ', '%20'))
        { resp ->
            if (resp.data)
            {
                logDebug("resp.data = ${resp.data}")
                result = resp.data
            }
        }
        
        return result
    }
    catch (Exception e)
    {
        logDebug("httpGetExec() failed: ${e.message}")
    }
}
1 Like

I see the delta here--sure. I do have an RPi currently sitting a retro game server, but would trash that for making this work. I saw the project from Mark Hank here -- but don't really want an external screen since i'm using tablets with fully kiosk

You can most likely run it without killing your game setup. You can run the node.js instance alongside other things. My RPi runs raspbian and I have multiple of these little applets running on it at all times.

1 Like

for now I am going to 'cheat' and incorporate my sharptools dashboard into hubitat as sharptools makes it easy to have cover art...using the same smarlty backgroun--for $30 a year...money already spent so...

Fair enough. If it works, it works. :slight_smile: