Spotify Soloist media player

Introduction

Spotify issued Soloist not so long ago; a commandline client for Spotify Premium. After again issues with Moode in combination with Spotift I decided to give it a try. I now have a mediaplayer in Home Assistant operating Soloist. And it is also aware when my family uses it with their own accounts.

Preparation

I have installed the light version of Raspbian on my Pi 4. I also had to install PipeWire:

sudo apt install pipewire pipewire-pulse pipewire-alsa

Also install template-media-player through HACS. It lets you setup a mediaplayer interfacing with MQTT.
You also need to have MQTT running.
I am using a USB connected FX01 audio device. Set output through raspi-config --> System --> Audio

Soloist Installation

The instructions from Spotify are clear; wget it, unzip it and install it. You may want to read their whole page.

wget https://soloist-builds.spotifycdn.com/soloist_release_arm64.tar.gz
tar -xzf soloist.tar.gz
sudo install -m 755 soloist /usr/local/bin/soloist

Log off and on. You also need to obtain a key here. You can now run the server and enable websocket:

soloist --device-name "<Device name>" --api-key "<key>" --ws 0.0.0.0:9090

When it works; prepare a service. Change User to your login name

sudo nano /etc/systemd/system/soloist.service
[Unit]
Description=Spotify Soloist Service
After=network.target sound.target

[Service]
User=balk
Environment="XDG_RUNTIME_DIR=/run/user/1000"
ExecStart=/usr/local/bin/soloist --device-name "<Device name>" --api-key "<key>" --ws 0.0.0.0:9090 -v
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

and activate it

sudo systemctl daemon-reload
sudo systemctl enable soloist.service
sudo systemctl start soloist.service

You may need to do sudo loginctl enable-linger <username> to make it also work when you are not logged in.

The Soloist 90days thing

The player stops working every 90 days; this forces you to stay updated. I have cron job that runs every two months:

nano update_soloist.sh
#!/bin/bash

# ==========================================
# Soloist Auto-Updater
# ==========================================

# Define variables
SOLOIST_BIN="/usr/local/bin/soloist"
TEMP_DIR="/tmp/soloist_update"
# Replace this URL with the actual source where you download Soloist
DOWNLOAD_URL="https://github.com/librespot-org/soloist/releases/latest/download/soloist-linux-arm64"
SERVICE_NAME="soloist.service"

echo "Starting Soloist update process..."

# 1. Create a temporary directory
mkdir -p "$TEMP_DIR"
cd "$TEMP_DIR"

# 2. Download the latest version
echo "Downloading the latest version..."
if wget -q --show-progress "$DOWNLOAD_URL" -O soloist_new; then
    echo "Download successful."
else
    echo "Error: Could not download the latest version."
    exit 1
fi

# 3. Make the file executable
chmod +x soloist_new

# 4. Stop the current Soloist service (assuming you use systemd)
echo "Stopping Soloist service..."
sudo systemctl stop "$SERVICE_NAME"

# 5. Replace the old binary with the new one
echo "Installing the new binary..."
sudo mv soloist_new "$SOLOIST_BIN"

# 6. Restart the service
echo "Restarting Soloist service..."
sudo systemctl start "$SERVICE_NAME"

# 7. Test if the update was successful
echo "Checking the version..."
# Run the binary and capture the output
VERSION_OUTPUT=$($SOLOIST_BIN --version 2>&1)

# Check if the output contains the expected version info
if [[ "$VERSION_OUTPUT" == *"soloist"* ]]; then
    echo "Update successful! Current version:"
    echo "$VERSION_OUTPUT"
else
    echo "Warning: Could not verify the version. Output was:"
    echo "$VERSION_OUTPUT"
fi

# 8. Clean up
cd ~
rm -rf "$TEMP_DIR"
echo "Update script finished."

Make it executable and use cron to run it to run every first night of the second month.

0 3 1 */2 * /home/pi/update_soloist.sh > /home/pi/soloist_update.log 2>&1

Websocket

I am using NodeRed to interact with the websocket

Node Red code
[
    {
        "id": "a9d3a5a226b7f6ea",
        "type": "tab",
        "label": "Soloist",
        "disabled": false,
        "info": "",
        "env": []
    },
    {
        "id": "d793a5a197e96063",
        "type": "websocket in",
        "z": "a9d3a5a226b7f6ea",
        "name": "Woonkamer",
        "server": "",
        "client": "ddf8a26d19b53287",
        "x": 110,
        "y": 280,
        "wires": [
            [
                "3f742f07ab81caa6"
            ]
        ]
    },
    {
        "id": "df5fb4e4be77ae78",
        "type": "debug",
        "z": "a9d3a5a226b7f6ea",
        "name": "debug 7",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "false",
        "statusVal": "",
        "statusType": "auto",
        "x": 620,
        "y": 320,
        "wires": []
    },
    {
        "id": "b3ac83ba1a3f9e7a",
        "type": "websocket out",
        "z": "a9d3a5a226b7f6ea",
        "name": "Woonkamer",
        "server": "",
        "client": "ddf8a26d19b53287",
        "x": 630,
        "y": 440,
        "wires": []
    },
    {
        "id": "0d505f8c08a29264",
        "type": "inject",
        "z": "a9d3a5a226b7f6ea",
        "name": "Play",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "{\"type\":\"command\",\"command\":\"play\"}",
        "payloadType": "json",
        "x": 270,
        "y": 420,
        "wires": [
            [
                "b3ac83ba1a3f9e7a"
            ]
        ]
    },
    {
        "id": "484701010285dea1",
        "type": "inject",
        "z": "a9d3a5a226b7f6ea",
        "name": "Pause",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "{\"type\":\"command\",\"command\":\"pause\"}",
        "payloadType": "json",
        "x": 270,
        "y": 460,
        "wires": [
            [
                "b3ac83ba1a3f9e7a"
            ]
        ]
    },
    {
        "id": "f498e446eeade01e",
        "type": "inject",
        "z": "a9d3a5a226b7f6ea",
        "name": "Next",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "{\"type\":\"command\",\"command\":\"skip_next\"}",
        "payloadType": "json",
        "x": 270,
        "y": 500,
        "wires": [
            [
                "b3ac83ba1a3f9e7a"
            ]
        ]
    },
    {
        "id": "0d80ca5653515b36",
        "type": "inject",
        "z": "a9d3a5a226b7f6ea",
        "name": "Previous",
        "props": [
            {
                "p": "payload"
            }
        ],
        "repeat": "",
        "crontab": "",
        "once": false,
        "onceDelay": 0.1,
        "topic": "",
        "payload": "{\"type\":\"command\",\"command\":\"skip_prev\"}",
        "payloadType": "json",
        "x": 280,
        "y": 540,
        "wires": [
            [
                "b3ac83ba1a3f9e7a"
            ]
        ]
    },
    {
        "id": "3f742f07ab81caa6",
        "type": "json",
        "z": "a9d3a5a226b7f6ea",
        "name": "",
        "property": "payload",
        "action": "",
        "pretty": false,
        "x": 270,
        "y": 280,
        "wires": [
            [
                "4d3b588387ab3d9f"
            ]
        ]
    },
    {
        "id": "4d3b588387ab3d9f",
        "type": "function",
        "z": "a9d3a5a226b7f6ea",
        "name": "playback_state",
        "func": "// Get from memory, or new start\nlet currentState = context.get(\"soloistState\") || {\n    state: \"paused\",\n    title: \"Geen titel\",\n    artist: \"Onbekend\",\n    album_art: \"\",\n    volume: 0\n};\n\nif (!msg.payload) return null;\n\nif (msg.payload.type === \"playback_state\") {\n    \n    const item = msg.payload.item || {};\n    const decorations = item.decorations || {};\n\n    currentState.title = (decorations.identity && decorations.identity.name)\n        ? decorations.identity.name\n        : \"Geen titel\";\n\n    const creators = decorations.creators || [];\n    if (creators.length > 0 && creators[0].entity && creators[0].entity.decorations && creators[0].entity.decorations.identity) {\n        currentState.artist = creators[0].entity.decorations.identity.name;\n    } else {\n        currentState.artist = \"Onbekend\";\n    }\n\n    const covers = (decorations.visual_identity && decorations.visual_identity.cover)\n        ? decorations.visual_identity.cover\n        : [];\n    currentState.album_art = covers.length > 0 ? covers[covers.length - 1].url : \"\";\n\n    currentState.state = msg.payload.status || \"paused\";\n\n    \n    if (msg.payload.volume !== undefined) {\n        currentState.volume = msg.payload.volume / 100;\n    }\n\n} else if (msg.payload.type === \"volume_changed\") {\n    \n    if (msg.payload.volume !== undefined) {\n        currentState.volume = msg.payload.volume / 100;\n    }\n} else {\n    \n    return null;\n}\n\n\ncontext.set(\"soloistState\", currentState);\n\n\nmsg.payload = currentState;\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 440,
        "y": 280,
        "wires": [
            [
                "df5fb4e4be77ae78",
                "547d462e7ac99d20"
            ]
        ]
    },
    {
        "id": "547d462e7ac99d20",
        "type": "mqtt out",
        "z": "a9d3a5a226b7f6ea",
        "name": "",
        "topic": "soloist/state",
        "qos": "0",
        "retain": "",
        "respTopic": "",
        "contentType": "",
        "userProps": "",
        "correl": "",
        "expiry": "",
        "broker": "9140e11f198e1eec",
        "x": 630,
        "y": 260,
        "wires": []
    },
    {
        "id": "b2fad3e70a1afb01",
        "type": "mqtt in",
        "z": "a9d3a5a226b7f6ea",
        "name": "",
        "topic": "soloist/command",
        "qos": "2",
        "datatype": "auto-detect",
        "broker": "9140e11f198e1eec",
        "nl": false,
        "rap": true,
        "rh": 0,
        "inputs": 0,
        "x": 120,
        "y": 380,
        "wires": [
            [
                "a3def491ebbdfe8c"
            ]
        ]
    },
    {
        "id": "53aee743fd431131",
        "type": "debug",
        "z": "a9d3a5a226b7f6ea",
        "name": "debug 8",
        "active": true,
        "tosidebar": true,
        "console": false,
        "tostatus": false,
        "complete": "false",
        "statusVal": "",
        "statusType": "auto",
        "x": 620,
        "y": 380,
        "wires": []
    },
    {
        "id": "a3def491ebbdfe8c",
        "type": "function",
        "z": "a9d3a5a226b7f6ea",
        "name": "HA2WS",
        "func": "\nvar payloadStr = msg.payload.toString().toLowerCase();\nvar parts = payloadStr.split(\":\");\nvar action = parts[0];\n\nvar command = action;\nvar outPayload = {\n    \"type\": \"command\",\n    \"command\": command\n};\n\n\nif (action === \"next\" || action === \"skip_next\") {\n    outPayload.command = \"skip_next\";\n} else if (action === \"prev\" || action === \"previous\" || action === \"skip_prev\") {\n    outPayload.command = \"skip_prev\";\n}\n\nelse if (action === \"volume\" || action === \"set_volume\") {\n    outPayload.command = \"set_volume\";\n    if (parts.length > 1) {\n        \n        outPayload.volume = parseInt(parts[1], 10);\n    }\n}\n\nelse if (action === \"mute\") {\n    \n    outPayload.command = \"set_volume\";\n    outPayload.volume = 0;\n}\n\n\nmsg.payload = outPayload;\nreturn msg;",
        "outputs": 1,
        "timeout": 0,
        "noerr": 0,
        "initialize": "",
        "finalize": "",
        "libs": [],
        "x": 420,
        "y": 380,
        "wires": [
            [
                "53aee743fd431131",
                "b3ac83ba1a3f9e7a"
            ]
        ]
    },
    {
        "id": "ddf8a26d19b53287",
        "type": "websocket-client",
        "path": "ws://192.168.1.95:9090",
        "tls": "",
        "wholemsg": "false",
        "hb": "0",
        "subprotocol": "",
        "headers": []
    },
    {
        "id": "9140e11f198e1eec",
        "type": "mqtt-broker",
        "name": "",
        "broker": "192.168.1.7",
        "port": "1883",
        "clientid": "",
        "autoConnect": true,
        "usetls": false,
        "protocolVersion": "4",
        "keepalive": "60",
        "cleansession": true,
        "autoUnsubscribe": true,
        "birthTopic": "nodered",
        "birthQos": "0",
        "birthRetain": "false",
        "birthPayload": "",
        "birthMsg": {},
        "closeTopic": "",
        "closeQos": "0",
        "closeRetain": "false",
        "closePayload": "",
        "closeMsg": {},
        "willTopic": "",
        "willQos": "0",
        "willRetain": "false",
        "willPayload": "",
        "willMsg": {},
        "userProps": "",
        "sessionExpiry": ""
    }
]

It converts MQTT into WS and vice versa. Not all features are implemented; just the basics I needed.

Home Assistant setup

I have an MQTT sensor that reads the incoming playback state

mqtt:
  sensor:
    - name: "Soloist State"
        state_topic: "soloist/state"
        value_template: "{{ value_json.state }}"
        json_attributes_topic: "soloist/state"

Finally, create the media player

media_player:
- platform: template_media_player
  media_players:
    soloist_speler:
      name: "Spotify Soloist Speler"
      state: >
        {{ states('sensor.soloist_state') }}
      attributes:
        media_title: >
          {{ state_attr('sensor.soloist_state', 'title') }}
        media_artist: >
          {{ state_attr('sensor.soloist_state', 'artist') }}
        entity_picture: >
          {{ state_attr('sensor.soloist_state', 'album_art') }}
        volume_level: >
            {{ state_attr('sensor.soloist_state', 'volume') }}
      service_scripts:
        media_play:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: "play"
        media_pause:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: "pause"
        media_next_track:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: "skip_next"
        media_previous_track:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: "skip_prev"
        volume_set:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: "volume:{{ (volume * 100) | int }}"
        volume_mute:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: >
                {% if is_volume_muted %}
                  mute
                {% else %}
                  volume:30
                {% endif %}
        volume_up:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: >
                {% set current = state_attr('sensor.soloist_state', 'volume') | float(0) %}
                {% set new_vol = (current * 100) + 5 %}
                {% if new_vol > 100 %}{% set new_vol = 100 %}{% endif %}
                volume:{{ new_vol | int }}
                
        volume_down:
          - service: mqtt.publish
            data:
              topic: "soloist/command"
              payload: >
                {% set current = state_attr('sensor.soloist_state', 'volume') | float(0) %}
                {% set new_vol = (current * 100) - 5 %}
                {% if new_vol < 0 %}{% set new_vol = 0 %}{% endif %}
                volume:{{ new_vol | int }}

Have fun! This was written so I know how I did it :slight_smile:

1 Like