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 ![]()
