Spotify follows source mode, name and credentials live

- spotify_init() sets up the logger, zeroconf routes and session task
  once; spotify_apply() (at boot and on every source save) enables or
  disables: enabled = mode spotify/auto + credentials. Disable removes
  the mDNS entry, zeroconf answers 404 and a running session ends
  (loop exits within 200 ms, SpircHandler::disconnect stops the queue
  and player tasks). A new device name ends the session and
  re-advertises. The login blob is swapped under a mutex.
- Verified: hls/spotify/rename switch the mDNS entry and /spotify_info
  (404/200) live; switching to hls during Spotify playback ended the
  session in < 2 s, HLS played after ~5 s, internal heap 221 -> 365 KB
  (cspot frees everything).
- CLAUDE.md: OTA-with-session findings (intermittent, flash-write stalls
  seen as TX resyncs during uploads), serial-port reset caveat.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 19:43:16 +10:00
parent 8ba4de5eda
commit 0aac7bc339
4 changed files with 92 additions and 34 deletions
+5 -2
View File
@@ -22,9 +22,12 @@ typedef enum {
typedef size_t (*spotify_pcm_cb_t)(const int16_t *pcm, size_t frames);
typedef void (*spotify_event_cb_t)(spotify_event_t ev, int value);
// Registers the zeroconf endpoints (/spotify_info) and, when source.mode is spotify/auto and the
// client credentials are set, advertises the device and starts the session task.
// Registers the zeroconf endpoints (/spotify_info), starts the session task and applies the config.
esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event);
// Re-read the "source" config: enabled (advertised, sessions accepted) when mode is spotify/auto and
// the client credentials are set; otherwise not advertised and a running session is ended.
// A new device name ends the session and re-advertises.
void spotify_apply(void);
// Pause/resume the running session (the Spotify app follows). No-op without a session.
void spotify_pause(bool pause);
// Current state for status.spotify_state.
+82 -30
View File
@@ -5,6 +5,7 @@
#include <exception>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#include "BellLogger.h"
@@ -26,7 +27,11 @@ extern "C" {
static const char *TAG = "spotify";
static std::shared_ptr<cspot::LoginBlob> s_blob;
static std::shared_ptr<cspot::LoginBlob> s_blob; // guarded by s_mutex
static std::mutex s_mutex;
static std::string s_name; // advertised device name ("" = not advertised)
static std::atomic<bool> s_enabled{false};
static std::atomic<bool> s_stop{false}; // end the running session
static std::atomic<bool> s_got_blob{false};
static std::atomic<const char *> s_state{"disabled"};
static std::atomic<uint64_t> s_pcm_bytes{0};
@@ -93,9 +98,19 @@ public:
/* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */
static std::shared_ptr<cspot::LoginBlob> blob()
{
std::lock_guard<std::mutex> lock(s_mutex);
return s_blob;
}
static esp_err_t info_get(httpd_req_t *req)
{
std::string json = s_blob->buildZeroconfInfo();
auto b = blob();
if (!s_enabled || !b) {
return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Spotify Connect is disabled");
}
std::string json = b->buildZeroconfInfo();
httpd_resp_set_type(req, "application/json");
return httpd_resp_send(req, json.data(), json.size());
}
@@ -120,6 +135,10 @@ static std::string url_decode(const char *s, size_t n)
// The Spotify app posts its login blob (action=addUser&userName=...&blob=...&clientKey=...).
static esp_err_t info_post(httpd_req_t *req)
{
auto b = blob();
if (!s_enabled || !b) {
return httpd_resp_send_err(req, HTTPD_404_NOT_FOUND, "Spotify Connect is disabled");
}
if (req->content_len == 0 || req->content_len > BODY_MAX) {
return httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "bad body");
}
@@ -143,7 +162,7 @@ static esp_err_t info_post(httpd_req_t *req)
pos = end + 1;
}
ESP_LOGI(TAG, "zeroconf %s from \"%s\"", query["action"].c_str(), query["userName"].c_str());
s_blob->loadZeroconfQuery(query);
b->loadZeroconfQuery(query);
s_got_blob = true;
static const char ok[] = "{\"status\":101,\"spotifyError\":0,\"statusString\":\"ERROR-OK\"}";
httpd_resp_set_type(req, "application/json");
@@ -178,14 +197,15 @@ static void session(void)
int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble;
cJSON_Delete(src);
auto ctx = cspot::Context::createFromBlob(s_blob);
auto login = blob();
auto ctx = cspot::Context::createFromBlob(login);
ctx->config.clientId = client_id;
ctx->config.clientSecret = client_secret;
ctx->config.audioFormat = bitrate >= 320 ? AudioFormat_OGG_VORBIS_320
: bitrate >= 160 ? AudioFormat_OGG_VORBIS_160 : AudioFormat_OGG_VORBIS_96;
s_state = "connecting";
ctx->session->connectWithRandomAp();
auto token = ctx->session->authenticate(s_blob);
auto token = ctx->session->authenticate(login);
if (token.empty()) {
s_state = "login failed";
ESP_LOGE(TAG, "login failed (check the client ID/secret and that the account is Premium)");
@@ -238,19 +258,26 @@ static void session(void)
s_handler = handler;
s_state = "connected";
ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str());
while (true) {
ctx->session->handlePacket();
while (!s_stop) {
ctx->session->handlePacket(); // waits at most 200 ms
}
// Stops the queue and player tasks (waits for them), then closes the connection.
s_handler.reset();
handler->disconnect();
ESP_LOGI(TAG, "session ended");
}
static void spotify_task(void *arg)
{
while (true) {
s_state = "waiting for Spotify app";
while (!s_got_blob) {
while (!s_enabled || !s_got_blob) {
if (s_enabled) {
s_state = "waiting for Spotify app";
}
vTaskDelay(pdMS_TO_TICKS(500));
}
s_got_blob = false;
s_stop = false;
try {
session();
} catch (const std::exception &e) {
@@ -262,6 +289,47 @@ static void spotify_task(void *arg)
}
}
extern "C" void spotify_apply(void)
{
cJSON *src = cfg_get("source");
std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring;
bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] &&
cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0];
cJSON_Delete(src);
bool want = (mode == "spotify" || mode == "auto") && creds;
if (!want || name != s_name) {
// Disable, or re-advertise under a new name: end any session, drop the old entry.
s_enabled = false;
s_stop = true;
s_got_blob = false;
if (!s_name.empty()) {
mdns_service_remove("_spotify-connect", "_tcp");
ESP_LOGI(TAG, "Spotify Connect \"%s\" withdrawn", s_name.c_str());
s_name.clear();
}
}
if (!want) {
s_state = !creds ? "no client credentials" : "disabled (source mode)";
if (!creds && (mode == "spotify" || mode == "auto")) {
ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)");
}
return;
}
if (s_name.empty()) {
{
std::lock_guard<std::mutex> lock(s_mutex);
s_blob = std::make_shared<cspot::LoginBlob>(name);
}
mdns_txt_item_t txt[] = { { "VERSION", "1.0" }, { "CPath", "/spotify_info" }, { "Stack", "SP" } };
esp_err_t err = mdns_service_add(name.c_str(), "_spotify-connect", "_tcp", 80, txt, 3);
s_name = name;
ESP_LOGI(TAG, "Spotify Connect \"%s\" advertised (%s)", name.c_str(), esp_err_to_name(err));
}
s_enabled = true;
}
extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event)
{
s_pcm_cb = pcm;
@@ -269,29 +337,13 @@ extern "C" esp_err_t spotify_init(spotify_pcm_cb_t pcm, spotify_event_cb_t event
if (!bell::bellGlobalLogger) {
bell::bellGlobalLogger = new EspLogger();
}
cJSON *src = cfg_get("source");
std::string mode = cJSON_GetObjectItemCaseSensitive(src, "mode")->valuestring;
std::string name = cJSON_GetObjectItemCaseSensitive(src, "spotify_name")->valuestring;
bool creds = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring[0] &&
cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring[0];
cJSON_Delete(src);
if (mode != "spotify" && mode != "auto") {
s_state = "disabled (source mode)";
return ESP_OK;
}
if (!creds) {
s_state = "no client credentials";
ESP_LOGW(TAG, "not started: set the Spotify client ID and secret (web UI, Source)");
return ESP_OK;
}
s_blob = std::make_shared<cspot::LoginBlob>(name);
static const httpd_uri_t get = { .uri = "/spotify_info", .method = HTTP_GET, .handler = info_get, .user_ctx = nullptr };
static const httpd_uri_t post = { .uri = "/spotify_info", .method = HTTP_POST, .handler = info_post, .user_ctx = nullptr };
web_register_uri(&get);
web_register_uri(&post);
mdns_txt_item_t txt[] = { { "VERSION", "1.0" }, { "CPath", "/spotify_info" }, { "Stack", "SP" } };
esp_err_t err = mdns_service_add(name.c_str(), "_spotify-connect", "_tcp", 80, txt, 3);
ESP_LOGI(TAG, "Spotify Connect \"%s\" advertised (%s)", name.c_str(), esp_err_to_name(err));
return xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) == pdPASS ? ESP_OK : ESP_ERR_NO_MEM;
if (xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) != pdPASS) {
return ESP_ERR_NO_MEM;
}
spotify_apply();
return ESP_OK;
}