Costruire un client Spotify touch-native su Raspberry Pi: Soloist, Web API, PyQt/QML, systemd e Tailscale

Scarica SoloSpot v0.2.1 Documentazione Soloist

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:

A questi si aggiungono:

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:

  1. apparire come un normale dispositivo Spotify Connect;
  2. continuare a riprodurre musica indipendentemente dalla GUI;
  3. offrire un'interfaccia realmente pensata per touchscreen;
  4. permettere ricerca, playlist, album e libreria;
  5. sopravvivere a reboot e restart della GUI;
  6. essere amministrabile via SSH anche da reti completamente differenti;
  7. non richiedere l'apertura di porte sul router domestico;
  8. evitare di incorporare Client Secret nel software distribuito;
  9. 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.

Schema visuale del Raspberry Pi con Soloist, Web API, GUI e piano di management Tailscale.

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:

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.

Fotografia/render del Raspberry Pi con display touch e schermata Player di SoloSpot.

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.

Screenshot anonimizzato del flusso OAuth/PKCE e del callback locale.

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.

Screenshot delle viste Player, Search, Library e Queue della versione 0.2.1.

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.

Schema o screenshot anonimizzato della tailnet e di una sessione SSH remota.

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

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.rar

SoloSpot è 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.