Costruire un client Spotify touch-native su Raspberry Pi: Soloist, Web API, PyQt/QML, systemd e Tailscale
Il pacchetto contiene il codice Python/QML di SoloSpot. Spotify Soloist non deve essere redistribuito: l’installer deve recuperare il runtime dai canali ufficiali Spotify.
Abstract
L'obiettivo del progetto era trasformare un Raspberry Pi 5 con display touch in un dispositivo Spotify autonomo, non semplicemente eseguendo Spotify Web Player dentro Chromium, ma costruendo un'architettura più vicina a quella di una vera appliance embedded.
Il risultato è SoloSpot, una GUI custom sviluppata in Python/PyQt6/QML che utilizza due differenti interfacce Spotify:
- Spotify Soloist come motore di playback e endpoint Spotify Connect;
- Spotify Web API per ricerca, libreria e metadati.
A questi si aggiungono:
- systemd per il lifecycle del player;
- Tailscale per l'amministrazione remota via SSH senza esporre servizi su Internet;
- KDE/Wayland come ambiente grafico touch.
La scelta progettuale fondamentale è stata non costruire un'applicazione monolitica, ma separare nettamente control plane, data plane, presentation layer e management plane.
1. Requisiti
Il sistema doveva soddisfare alcuni requisiti abbastanza precisi.
Il Raspberry doveva:
- apparire come un normale dispositivo Spotify Connect;
- continuare a riprodurre musica indipendentemente dalla GUI;
- offrire un'interfaccia realmente pensata per touchscreen;
- permettere ricerca, playlist, album e libreria;
- sopravvivere a reboot e restart della GUI;
- essere amministrabile via SSH anche da reti completamente differenti;
- non richiedere l'apertura di porte sul router domestico;
- evitare di incorporare Client Secret nel software distribuito;
- essere replicabile su più Raspberry senza modificare il codice.
Questi requisiti hanno portato naturalmente a separare il sistema in più componenti.
2. Architettura generale
La topologia logica è questa:
flowchart TB
Spotify["Spotify Cloud"]
subgraph Raspberry["Raspberry Pi"]
Soloist["Spotify Soloist<br/>Playback Engine"]
WS["Local WebSocket API<br/>127.0.0.1"]
Backend["SoloSpot Backend<br/>Python / PyQt6"]
UI["SoloSpot UI<br/>QML / Qt Quick"]
Audio["PipeWire / PulseAudio<br/>Audio Output"]
Soloist --> Audio
Soloist <--> WS
WS <--> Backend
Backend <--> UI
end
Spotify <-->|Spotify Connect| Soloist
Spotify <-->|HTTPS / Web API| Backend
Remote["Remote PC / Mac"]
Tailnet["Tailscale / WireGuard"]
Remote <--> Tailnet
Tailnet <--> Raspberry
Il punto fondamentale è che SoloSpot non riproduce direttamente Spotify.
Il percorso audio è:
Spotify
│
▼
Soloist
│
▼
PipeWire / PulseAudio
│
▼
Audio device
La GUI è semplicemente un controller:
SoloSpot
│
▼
WebSocket locale
│
▼
Soloist
Mentre ricerca e libreria seguono un percorso completamente diverso:
SoloSpot
│
│ HTTPS + OAuth
▼
Spotify Web API
Questa separazione è intenzionale.
3. Perché non usare Spotify Web Player
La soluzione inizialmente più semplice sarebbe stata:
Raspberry Pi
│
▼
Chromium --kiosk
│
▼
open.spotify.com
Tecnicamente funziona.
Ma introduce diversi problemi.
Il browser diventa contemporaneamente:
- runtime;
- player;
- session manager;
- UI;
- storage dell'autenticazione;
- dependency principale.
Inoltre l'interfaccia Spotify Web rimane progettata per desktop/browser, non necessariamente per un display touch embedded da 7–10".
La soluzione che cercavo era invece:
player headless stabile
+
UI indipendente
Spotify Soloist ha reso possibile esattamente questo modello.
4. Spotify Soloist come playback engine
Spotify Soloist è un client Linux headless che può comportarsi come dispositivo Spotify Connect.
Una volta associato, compare nell'app Spotify ufficiale tra i normali dispositivi disponibili. Spotify indica esplicitamente Raspberry Pi tra i casi d'uso di Soloist.
Il demone viene avviato concettualmente così:
soloist \
--device-name "Music-Tablet" \
--api-key "$SOLOIST_API_KEY" \
--data-dir "$HOME/.local/share/soloist" \
--cache-dir "$HOME/.cache/soloist" \
--ws 127.0.0.1:9090
I parametri principali sono:
--device-name nome visibile in Spotify Connect
--api-key Soloist API Key
--data-dir stato persistente
--cache-dir cache volatile
--ws WebSocket API locale
Spotify documenta che il data directory contiene, tra le altre cose:
device identity
stored Spotify Connect session
runtime state
soloist.pid
ws.addr
ws.port
Eliminare quella directory equivale quindi, di fatto, a resettare l'identità persistente del dispositivo.
Questo è importante per il comportamento dopo un reboot.
5. logged_in non significa active
Durante i test è emersa una distinzione importante.
Soloist può trovarsi nello stato:
logged in: yes
active device: no
Non significa che l'autenticazione sia andata persa.
Significa:
logged_in = l'account Spotify è associato
active = questo device è attualmente il target della riproduzione
La WebSocket di Soloist espone un evento auth_state; quando un client si connette riceve immediatamente questo stato e, se l'utente è autenticato, riceve anche lo stato del playback.
Nella GUI posso quindi implementare una logica simile:
def handle_auth_state(event):
logged_in = event.get("logged_in", False)
active = event.get("is_active", False)
if logged_in:
get_playback_state()
get_queue()
if not active:
activate_device()
Nel mio caso l'attivazione automatica viene tentata una sola volta all'avvio.
Questo evita un comportamento fastidioso: se successivamente sposto volontariamente Spotify su un altro altoparlante, SoloSpot non continua a "rubare" il playback riportandolo sul Raspberry.
6. WebSocket locale: SoloSpot ↔ Soloist
Soloist espone opzionalmente una JSON WebSocket API.
Nel progetto è volutamente aperta solo su:
127.0.0.1:9090
e non su:
0.0.0.0
La ragione è importante.
Spotify specifica che questa API locale non implementa autonomamente autenticazione client, TLS, Origin validation, CSRF protection o una politica di esposizione di rete. È una superficie pensata per integrazioni locali.
Quindi il trust boundary è:
flowchart LR
Internet["Internet"]
LAN["LAN"]
Tailnet["Tailscale"]
subgraph Host["Raspberry Pi"]
UI["SoloSpot"]
WS["127.0.0.1:9090"]
Soloist["Soloist"]
UI <--> WS
WS <--> Soloist
end
Internet -. X .-> WS
LAN -. X .-> WS
Tailnet -. X .-> WS
La WebSocket non ha motivo di essere raggiungibile dall'esterno.
7. Protocollo WebSocket
I messaggi sono frame JSON.
Ogni comando client → Soloist utilizza:
{
"type": "command",
"command": "pause"
}
Un client Python minimale può assomigliare a questo:
import json
def command(ws, name, **params):
payload = {
"type": "command",
"command": name
}
payload.update(params)
ws.sendTextMessage(
json.dumps(payload)
)
Per esempio:
command(ws, "pause")
command(ws, "skip_next")
command(ws, "seek", position_ms=120000)
command(ws, "set_volume", volume=65)
Oppure:
command(
ws,
"play",
uri="spotify:album:..."
)
Soloist espone anche:
get_state
get_queue
activate
deactivate
set_shuffle
set_repeat_context
set_repeat_track
add_to_queue
oltre agli eventi necessari per mantenere sincronizzata l'interfaccia.
8. Sincronizzare la timeline senza interrogare continuamente Soloist
Un dettaglio interessante riguarda la progress bar.
Non è necessario chiedere a Soloist la posizione del brano ogni mezzo secondo.
Soloist invia dati contenenti:
position_ms
timestamp_ms
speed
Posso quindi utilizzare un anchor temporale:
position_anchor = event["position_ms"]
timestamp_anchor = event["timestamp_ms"]
speed = event["speed"]
e interpolare localmente:
now_ms = int(time.time() * 1000)
position = (
position_anchor
+ (now_ms - timestamp_anchor) * speed
)
Se:
speed = 1
il brano è in avanzamento normale.
Se:
speed = 0
è fermo o in pausa.
La GUI può così aggiornarsi a 500 ms senza generare continuamente traffico WebSocket.
9. Perché serve anche Spotify Web API
Soloist risolve bene il playback, ma non sostituisce l'intera Spotify Web API.
Per costruire un'interfaccia completa servono dati come:
search
playlist personali
album salvati
brani salvati
recently played
top artists / tracks
metadata
Il modello diventa:
flowchart LR
UI["SoloSpot UI"]
UI -->|"Search / Library"| API["Spotify Web API"]
API -->|"Metadata + URI"| UI
UI -->|"spotify:URI"| Soloist["Soloist"]
Soloist -->|"Playback State"| UI
Per esempio una ricerca:
GET /v1/search
?q=pink%20floyd
&type=track,album,playlist
&limit=10
restituisce gli oggetti del catalogo.
SoloSpot normalizza poi i risultati:
{
"name": "Album title",
"subtitle": "Artist",
"image": "https://...",
"uri": "spotify:album:...",
"kind": "album"
}
La GUI non deve conoscere il formato completo restituito da Spotify.
Questo crea un utile anti-corruption layer tra API esterna e presentation layer.
10. OAuth: Authorization Code + PKCE
L'applicazione gira fisicamente sul dispositivo dell'utente.
Non esiste un backend server sicuro nel quale nascondere un Client Secret.
Per questo il modello corretto è Authorization Code Flow with PKCE.
Spotify raccomanda PKCE per applicazioni nelle quali il Client Secret non può essere mantenuto realmente segreto.
Il flusso è:
sequenceDiagram
participant App as SoloSpot
participant Browser
participant Spotify as Spotify Accounts
participant Callback as Loopback Callback
App->>App: Generate code_verifier
App->>App: SHA256 → code_challenge
App->>Browser: Open /authorize
Browser->>Spotify: User login / consent
Spotify->>Callback: authorization code
Callback->>App: code
App->>Spotify: code + code_verifier
Spotify-->>App: access_token + refresh_token
Un esempio semplificato:
import secrets
import hashlib
import base64
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(
verifier.encode()
).digest()
challenge = base64.urlsafe_b64encode(
digest
).rstrip(b"=").decode()
La richiesta di autorizzazione contiene:
response_type=code
client_id=<CLIENT_ID>
redirect_uri=<LOOPBACK_CALLBACK>
code_challenge_method=S256
code_challenge=<CHALLENGE>
state=<RANDOM_STATE>
scope=<REQUIRED_SCOPES>
Il state è importante per proteggere il callback OAuth da richieste non correlate alla sessione iniziata dall'app. Spotify lo raccomanda esplicitamente.
11. Perché 127.0.0.1 e non localhost
Per il callback OAuth viene utilizzato un indirizzo loopback esplicito:
http://127.0.0.1:<PORT>/callback
e non:
http://localhost:<PORT>/callback
Spotify permette HTTP per indirizzi loopback espliciti come 127.0.0.1 o [::1], mentre localhost non è accettato come Redirect URI.
La callback HTTP può essere estremamente minimale.
Per esempio:
from http.server import BaseHTTPRequestHandler
from http.server import ThreadingHTTPServer
server = ThreadingHTTPServer(
("127.0.0.1", CALLBACK_PORT),
OAuthHandler
)
server.handle_request()
Dopo l'autorizzazione:
Spotify
│
▼
127.0.0.1:<PORT>/callback
│
▼
SoloSpot
Non viene aperto nessun listener sulla LAN.
12. Client ID, Client Secret e Soloist API Key non sono la stessa cosa
Questo è uno dei punti che inizialmente può creare più confusione.
Esistono almeno tre oggetti distinti:
Spotify Web API Client ID
≠
Spotify Web API Client Secret
≠
Spotify Soloist API Key
Nel nostro modello:
Client ID
→ utilizzato da SoloSpot / OAuth PKCE
Client Secret
→ NON inserito nell'applicazione
Soloist API Key
→ utilizzata esclusivamente dal daemon Soloist
Spotify specifica inoltre che la Soloist API Key è personale e non deve essere condivisa.
La configurazione deve quindi contenere qualcosa del tipo:
{
"spotify_client_id": "<CLIENT_ID>",
"redirect_uri": "http://127.0.0.1:<PORT>/callback"
}
mentre la chiave Soloist viene mantenuta separatamente.
13. Una nota di sicurezza sull'API Key Soloist
Soloist richiede attualmente la API Key tramite parametro CLI:
--api-key KEY
Questo significa che, anche se la chiave viene conservata inizialmente in un file con:
chmod 600
quando systemd espande:
ExecStart=... --api-key ${SOLOIST_API_KEY}
il valore può diventare visibile nell'argv del processo a utenti locali con privilegi sufficienti e può comparire in alcuni output diagnostici.
Per questo motivo considero sensibili anche screenshot o dump di comandi come:
systemctl status ...
ps ...
Il threat model del dispositivo deve tenere conto di questo comportamento.
14. systemd come supervisor
Soloist è un daemon.
La GUI non dovrebbe essere responsabile del suo lifecycle.
Utilizzo quindi un systemd user service concettualmente simile a:
[Unit]
Description=Spotify playback engine
[Service]
Type=simple
EnvironmentFile=%h/.config/myapp/player.env
ExecStart=%h/.local/bin/soloist \
--device-name ${SOLOIST_DEVICE_NAME} \
--api-key ${SOLOIST_API_KEY} \
--data-dir %h/.local/share/soloist \
--cache-dir %h/.cache/soloist \
--ws 127.0.0.1:9090
Restart=on-failure
RestartSec=3
[Install]
WantedBy=default.target
Il risultato è una netta separazione:
flowchart TB
systemd["systemd --user"]
Soloist["Soloist daemon"]
KDE["KDE / Wayland"]
SoloSpot["SoloSpot GUI"]
systemd --> Soloist
KDE --> SoloSpot
SoloSpot -. WebSocket .-> Soloist
Posso quindi chiudere SoloSpot:
GUI: OFF
Playback: ON
e riaprirlo successivamente:
GUI: ON
Playback: già esistente
15. PyQt6 + QML
Per il presentation layer ho scelto:
Python
+
PyQt6
+
Qt Quick / QML
Il motivo è separare chiaramente logica e rendering.
Python gestisce:
OAuth
REST API
WebSocket
token refresh
state management
normalizzazione dati
QML gestisce:
layout
touch
animazioni
navigation
ListView
Slider
cover art
responsive UI
L'oggetto Python viene esposto al QML:
engine.rootContext().setContextProperty(
"backend",
backend
)
e QML può utilizzare direttamente proprietà:
Text {
text: backend.title
}
Image {
source: backend.coverUrl
}
o chiamare metodi:
Button {
text: "Next"
onClicked: backend.next()
}
La GUI rimane quindi molto sottile.
16. Reactive state
Il backend Qt espone proprietà con signal associati:
class Backend(QObject):
stateChanged = pyqtSignal()
@pyqtProperty(
str,
notify=stateChanged
)
def title(self):
return self._title
Quando Soloist genera:
track_changed
il backend aggiorna:
self._title = ...
self._artist = ...
self._cover = ...
self.stateChanged.emit()
QML ridisegna automaticamente gli elementi interessati.
Non c'è quindi polling della UI.
17. Search: debugging per boundary
Un problema interessante incontrato durante lo sviluppo è stato questo:
Player: OK
Search UI: KO
La tentazione sarebbe stata iniziare a modificare OAuth o le credenziali.
Invece ho verificato direttamente il boundary API:
api = SpotifyAPI()
result = api._get_json(
"/search",
{
"q": "test artist",
"type": "track,album,playlist",
"limit": 3
}
)
print(len(result["tracks"]["items"]))
print(len(result["albums"]["items"]))
print(len(result["playlists"]["items"]))
Il risultato era equivalente a:
OAuth OK
Client ID OK
Tracks OK
Albums OK
Playlists OK
Quindi:
Spotify API ✓
Networking ✓
OAuth ✓
Token refresh ✓
Presentation layer ✗
Il bug era nella gestione del model QML.
È un buon esempio di quanto sia utile un'architettura con boundary testabili indipendentemente.
18. Tailscale come management plane
Il Raspberry deve poter essere amministrato anche quando si trova dietro:
NAT
CGNAT
router domestici
Wi-Fi differenti
firewall
La soluzione classica sarebbe:
port forwarding
+
public IP / DDNS
+
SSH exposed
Ho scelto di non farlo.
Tailscale costruisce una rete privata mesh utilizzando WireGuard e aggiunge NAT traversal, identity e controllo degli accessi.
Il management plane diventa:
flowchart LR
Laptop["Laptop"]
Internet["Internet"]
Tailnet["Tailscale"]
Pi["Raspberry Pi"]
SSH["sshd"]
Laptop --> Tailnet
Tailnet --> Internet
Internet --> Tailnet
Tailnet --> Pi
Pi --> SSH
Ma dal punto di vista logico:
ssh user@TAILSCALE_ADDRESS
è sufficiente.
Non è necessario pubblicare SSH sull'IP Internet del Raspberry.
19. Direct connection e DERP
Tailscale tenta di stabilire una connessione UDP diretta tra i due peer mediante NAT traversal.
Quando è possibile:
Laptop ═════════ WireGuard ═════════ Raspberry
Se la connessione diretta non è possibile può utilizzare un peer relay oppure un relay DERP:
Laptop
║ encrypted
▼
DERP
║ encrypted
▼
Raspberry
Anche nel caso DERP il traffico rimane end-to-end encrypted con WireGuard; il relay non possiede le chiavi necessarie per decifrarlo.
È una differenza importante rispetto alla concezione classica:
client → VPN gateway → LAN
Tailscale cerca invece una mesh:
node ↔ node
20. Tailscale non trasporta Spotify Connect
Questa distinzione è importante.
Nel progetto Tailscale non viene utilizzato per far funzionare Spotify.
Le responsabilità sono:
Spotify Connect
→ discovery/playback Spotify
localhost
→ SoloSpot ↔ Soloist
Spotify HTTPS
→ Web API
Tailscale
→ SSH / management
Spotify Connect utilizza discovery locale e Soloist richiede, per il primo pairing, che l'app Spotify possa trovare il dispositivo sulla rete locale. Spotify descrive esplicitamente questo passaggio nel setup Soloist.
Tailscale lavora principalmente al livello IP e non trasforma automaticamente due reti remote in un unico dominio broadcast/multicast Layer 2.
Questa è la ragione per cui non ho cercato di usare Tailscale come sostituto del discovery Spotify Connect.
21. Security boundaries
Il modello di sicurezza complessivo è quindi:
flowchart TB
Internet["Public Internet"]
subgraph Tailnet["Trusted Tailnet"]
Admin["Admin workstation"]
SSH["SSH"]
end
subgraph Raspberry["Raspberry Pi"]
SoloSpot["SoloSpot"]
Soloist["Soloist"]
LocalWS["WebSocket<br/>loopback only"]
OAuthCB["OAuth callback<br/>loopback only"]
Secrets["Local secrets<br/>0600"]
end
Spotify["Spotify"]
Admin --> SSH
SSH --> Raspberry
SoloSpot <--> LocalWS
LocalWS <--> Soloist
SoloSpot <--> Spotify
Soloist <--> Spotify
OAuthCB --> SoloSpot
Internet -. no direct SSH .-> Raspberry
Internet -. no WebSocket .-> LocalWS
Principi applicati:
No port forwarding
No public SSH
No public Soloist WebSocket
No Client Secret embedded
Loopback OAuth callback
Persistent tokens outside source tree
Separate playback and UI processes
Remote management isolated through tailnet
22. Replicabilità
Un requisito emerso successivamente è stato poter replicare il sistema su un secondo Raspberry.
Il software rimane identico:
SoloSpot code
Soloist binary
systemd unit
Python runtime
QML
Cambiano solamente:
device identity
device name
local Soloist state
OAuth tokens
local configuration
Il provisioning può quindi diventare:
install
│
├── dependencies
├── SoloSpot
├── Soloist
├── systemd service
└── desktop launcher
configure
│
├── Client ID
├── Soloist API Key
└── Device name
pair
│
└── Spotify Connect
authorize
│
└── OAuth PKCE
Questa separazione porta il progetto da:
"Raspberry configurato a mano"
a:
"appliance riproducibile"
23. Il risultato
L'attuale stack è:
┌─────────────────────────────────────────┐
│ SoloSpot │
│ │
│ Player Search Library Queue │
└───────────────────┬─────────────────────┘
│
┌─────────┴──────────┐
│ │
▼ ▼
Soloist WS Spotify Web API
│ │
▼ │
Spotify Connect │
│ │
└──────────┬─────────┘
▼
Spotify
Sul piano infrastrutturale:
┌─────────────┐
│ systemd │
└──────┬──────┘
│
Soloist
│
Audio
KDE / Wayland
│
SoloSpot
Sul piano amministrativo:
Remote workstation
│
│ SSH
▼
Tailscale
│
▼
Raspberry Pi
24. Cosa migliorerei nella prossima iterazione
L'architettura consente ormai di aggiungere funzionalità senza modificare il playback engine.
Le evoluzioni più interessanti sono:
Home personalizzata
album / playlist detail views
gesture touch
queue editor
persistent mini-player
audio device selector
GPIO media controls
physical rotary encoder
automatic UI startup
power management
screen blanking awareness
network state indicator
health monitoring
structured logging
automatic Soloist update check
Quest'ultimo punto merita particolare attenzione.
Spotify specifica attualmente che le build Soloist hanno una durata di 90 giorni e devono quindi essere periodicamente sostituite con una versione aggiornata. Spotify vieta inoltre di redistribuire direttamente gli archivi Soloist: ogni installazione deve recuperare la build ufficiale.
Per questo un installer corretto dovrebbe implementare:
SoloSpot package
│
├── nostro codice
│
└── download runtime
│
▼
Spotify official CDN
e non:
SoloSpot.zip
└── soloist binary
25. Considerazioni finali
La parte interessante del progetto non è aver fatto funzionare Spotify su un Raspberry Pi.
Quello si può ottenere in molti modi.
La parte interessante è l'architettura risultante:
RESPONSIBILITY
Soloist → playback
Web API → discovery / metadata
Python → orchestration
QML → presentation
systemd → process lifecycle
PipeWire → audio
Tailscale → remote management
Spotify → identity / content / Connect
Nessun componente deve simulare funzioni appartenenti agli altri.
Questo ha diversi vantaggi.
Un crash della GUI non interrompe necessariamente la musica.
Un errore nella ricerca non coinvolge il playback.
La WebSocket del player non deve essere esposta in rete.
Il Raspberry può essere amministrato senza pubblicare SSH.
La GUI può essere completamente ridisegnata senza cambiare Spotify Connect.
Un secondo dispositivo può essere creato utilizzando lo stesso codebase e una configurazione locale differente.
In altre parole, quello che era nato come:
"voglio Spotify sul display del Raspberry"
si è trasformato progressivamente in una piccola architettura embedded:
┌────────── Spotify ──────────┐
│ │
▼ ▼
Spotify Connect Web API
│ │
▼ ▼
Soloist ◄── WebSocket ──► SoloSpot
│ │
▼ ▼
Audio Touch UI
Management plane
Laptop ── Tailscale ── SSH
│
▼
Raspberry
Ed è proprio la separazione tra playback plane, application plane e management plane che rende il sistema molto più interessante di un semplice browser in modalità kiosk.
Riferimenti tecnici
- Raspberry Pi — sito ufficiale
- Spotify Soloist — documentazione ufficiale
- Spotify Soloist — reference CLI, WebSocket e ctl
- Spotify Web API
- Spotify — Authorization Code with PKCE
- Spotify — Redirect URI requirements
- Tailscale
- Tailscale — direct, peer relay e DERP connections
- WireGuard
- Qt — QML Applications
- Qt Quick
- systemd
Download SoloSpot v0.2.1
La release usata nell’articolo può essere scaricata direttamente dal sito. Prima dell’uso occorre configurare il proprio Client ID Spotify e la propria Soloist API Key. Nessuna credenziale è inclusa nel pacchetto.
Scarica ./src/SoloSpot-RPi5-v0.2.1.rarSoloSpot è un progetto indipendente. Spotify, Spotify Connect e Spotify Soloist sono marchi/servizi di Spotify; Raspberry Pi è un marchio della Raspberry Pi Foundation/Trading; Tailscale e gli altri marchi citati appartengono ai rispettivi titolari.