#include "spotify.h" #include #include #include #include #include #include #include #include #include "BellLogger.h" #include "CSpotContext.h" #include "LoginBlob.h" #include "PlaybackState.h" #include "SpircHandler.h" #include "TrackPlayer.h" #include "esp_log.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "mdns.h" extern "C" { #include "aes67_cfg.h" #include "aes67_web.h" } #define BODY_MAX 8192 static const char *TAG = "spotify"; static std::shared_ptr s_blob; // guarded by s_mutex static std::mutex s_mutex; static std::string s_name; // advertised device name ("" = not advertised) static std::atomic s_enabled{false}; static std::atomic s_stop{false}; // end the running session static std::atomic s_got_blob{false}; static std::atomic s_in_session{false}; // session() running (its tasks may exist) static std::atomic s_volume{65535}; // player volume in Spotify units (0..65535) static std::atomic s_state{"disabled"}; static std::atomic s_pcm_bytes{0}; static std::shared_ptr s_handler; // running session, else null static spotify_pcm_cb_t s_pcm_cb; static spotify_event_cb_t s_event_cb; static spotify_pos_cb_t s_written, s_played; // Track boundaries in the output buffer: when playback passes one, the new track is audible and // Spotify must be told (notifyAudioReachedPlayback); without it the app's state stalls after the // first track and it drops the device. Guarded by s_bound_mutex (data callback vs session loop). struct Boundary { uint32_t pos; std::string id; }; static std::mutex s_bound_mutex; static std::deque s_bounds; static std::string s_last_id; // track of the most recent data callback static std::atomic s_depleted{false}; static std::mutex s_cmd_mutex; enum { CMD_VOLUME = 100 }; // internal: push s_volume to the app struct Command { int cmd; // spotify_cmd_t or CMD_VOLUME uint32_t arg; }; static std::deque s_cmds; // from spotify_command(), run by the session loop static uint32_t s_cmd_queued; // commands queued so far (s_cmd_mutex) static std::atomic s_cmd_done{0}; // commands run so far static std::mutex s_track_mutex; static cspot::TrackInfo s_track; // audible track (TRACK_INFO), guarded by s_track_mutex static void clear_boundaries(bool new_load) { std::lock_guard lock(s_bound_mutex); s_bounds.clear(); if (new_load) { s_last_id.clear(); // the next data (new track) marks a boundary } } // Session loop, every <= 200 ms. static void check_playback(const std::shared_ptr &handler) { if (!s_written || !s_played) { return; } uint32_t played = s_played(); std::string reached; { std::lock_guard lock(s_bound_mutex); while (!s_bounds.empty() && (int32_t)(played - s_bounds.front().pos) >= 0) { reached = s_bounds.front().id; // latest boundary passed wins s_bounds.pop_front(); } } if (!reached.empty()) { handler->notifyAudioReachedPlayback(reached); ESP_LOGI(TAG, "track audible, Spotify notified"); } if (s_depleted && played == s_written()) { s_depleted = false; handler->notifyAudioEnded(); ESP_LOGI(TAG, "playlist ended, Spotify notified"); } } extern "C" void spotify_suspend(bool suspend) { if (!suspend) { spotify_apply(); return; } s_enabled = false; s_stop = true; s_got_blob = false; s_state = "suspended"; for (int i = 0; i < 30 && s_in_session; i++) { // session loop exits within 200 ms, then disconnect vTaskDelay(pdMS_TO_TICKS(100)); } ESP_LOGI(TAG, "suspended%s", s_in_session ? " (session still ending)" : ""); } extern "C" void spotify_set_volume(int pct) { pct = pct < 0 ? 0 : pct > 100 ? 100 : pct; int v = pct * 65535 / 100; if (v == s_volume) { return; } s_volume = v; if (s_handler) { std::lock_guard lock(s_cmd_mutex); s_cmds.push_back({ CMD_VOLUME, 0 }); ++s_cmd_queued; } } extern "C" bool spotify_command(spotify_cmd_t cmd, uint32_t arg) { if (!s_handler) { return false; } uint32_t seq; { std::lock_guard lock(s_cmd_mutex); s_cmds.push_back({ cmd, arg }); seq = ++s_cmd_queued; } // Wait until it has run (the loop runs every <= 200 ms), so callers can report the new state. for (int i = 0; i < 50 && (int32_t)(s_cmd_done - seq) < 0 && s_handler; i++) { vTaskDelay(pdMS_TO_TICKS(20)); } return true; } // Session loop: run queued commands on the task that also handles Spotify's frames. static void run_commands(const std::shared_ptr &handler) { std::deque cmds; uint32_t seq; { std::lock_guard lock(s_cmd_mutex); cmds.swap(s_cmds); seq = s_cmd_queued; } for (auto [cmd, arg] : cmds) { switch (cmd) { case SPOTIFY_CMD_PLAY: case SPOTIFY_CMD_PAUSE: handler->setPause(cmd == SPOTIFY_CMD_PAUSE); // notifies the app, sends PLAY_PAUSE break; case SPOTIFY_CMD_NEXT: case SPOTIFY_CMD_PREV: // The new track's load notifies the app and flushes our buffer (PLAYBACK_START). if (!(cmd == SPOTIFY_CMD_NEXT ? handler->nextSong() : handler->previousSong())) { ESP_LOGW(TAG, "no %s track", cmd == SPOTIFY_CMD_NEXT ? "next" : "previous"); } break; case SPOTIFY_CMD_SEEK: // As for a seek from the app (SpircHandler::handleFrame): the buffered audio is void. handler->getTrackPlayer()->seekMs(arg ? arg : 1); // 0 means "no seek" to TrackPlayer handler->updatePositionMs(arg); // notifies the app clear_boundaries(false); s_depleted = false; if (s_event_cb) { s_event_cb(SPOTIFY_EV_FLUSH, 0); } break; case CMD_VOLUME: handler->setRemoteVolume(s_volume); // notifies the app break; } ESP_LOGI(TAG, "command %d %lu", cmd, (unsigned long)arg); } s_cmd_done = seq; } extern "C" bool spotify_session_active(void) { return s_handler != nullptr; } extern "C" bool spotify_now_playing(spotify_track_t *t) { auto h = s_handler; if (!h) { return false; } { std::lock_guard lock(s_track_mutex); strlcpy(t->artist, s_track.artist.c_str(), sizeof(t->artist)); strlcpy(t->title, s_track.name.c_str(), sizeof(t->title)); strlcpy(t->album, s_track.album.c_str(), sizeof(t->album)); t->duration_ms = s_track.duration; } // Same position the app shows: last reported position, plus the time since while playing. const auto &st = h->getPlaybackState()->innerFrame.state; t->paused = st.status != PlayStatus_kPlayStatusPlay; uint64_t pos = st.position_ms; if (!t->paused) { pos += h->getContext()->timeProvider->getSyncedTimestamp() - st.position_measured_at; } t->position_ms = t->duration_ms && pos > t->duration_ms ? t->duration_ms : (uint32_t)pos; return true; } const char *spotify_state(void) { return s_state.load(); } /* ----- cspot/bell logging -> esp_log (UART + syslog) ----- */ // cspot logs through bell::bellGlobalLogger; without one, CSPOT_LOG dereferences NULL. class EspLogger : public bell::AbstractLogger { static void out(esp_log_level_t lvl, const std::string &file, int line, const std::string &sub, const char *fmt, va_list ap) { char msg[256]; vsnprintf(msg, sizeof(msg), fmt, ap); // CDN URLs carry signed tokens: keep them out of the log (syslog may leave the box). char *q = strstr(msg, "?__token__="); if (q) { strcpy(q, "?__token__=..."); } size_t slash = file.find_last_of('/'); const char *base = file.c_str() + (slash == std::string::npos ? 0 : slash + 1); ESP_LOG_LEVEL(lvl, sub.empty() ? "cspot" : sub.c_str(), "%s:%d: %s", base, line, msg); } public: void debug(std::string file, int line, std::string sub, const char *fmt, ...) override { va_list ap; va_start(ap, fmt); out(ESP_LOG_DEBUG, file, line, sub, fmt, ap); va_end(ap); } void info(std::string file, int line, std::string sub, const char *fmt, ...) override { va_list ap; va_start(ap, fmt); out(ESP_LOG_INFO, file, line, sub, fmt, ap); va_end(ap); } void error(std::string file, int line, std::string sub, const char *fmt, ...) override { va_list ap; va_start(ap, fmt); out(ESP_LOG_ERROR, file, line, sub, fmt, ap); va_end(ap); } }; /* ----- zeroconf (Spotify Connect discovery) on our httpd ----- */ static std::shared_ptr blob() { std::lock_guard lock(s_mutex); return s_blob; } static esp_err_t info_get(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"); } std::string json = b->buildZeroconfInfo(); httpd_resp_set_type(req, "application/json"); return httpd_resp_send(req, json.data(), json.size()); } static std::string url_decode(const char *s, size_t n) { std::string out; for (size_t i = 0; i < n; i++) { if (s[i] == '+') { out += ' '; } else if (s[i] == '%' && i + 2 < n && isxdigit((unsigned char)s[i + 1]) && isxdigit((unsigned char)s[i + 2])) { char hex[3] = { s[i + 1], s[i + 2], 0 }; out += (char)strtol(hex, nullptr, 16); i += 2; } else { out += s[i]; } } return out; } // 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"); } std::string body(req->content_len, '\0'); size_t got = 0; while (got < body.size()) { int r = httpd_req_recv(req, body.data() + got, body.size() - got); if (r <= 0) { return ESP_FAIL; } got += r; } std::map query; size_t pos = 0; while (pos < body.size()) { size_t amp = body.find('&', pos), end = amp == std::string::npos ? body.size() : amp; size_t eq = body.find('=', pos); if (eq != std::string::npos && eq < end) { query[url_decode(&body[pos], eq - pos)] = url_decode(&body[eq + 1], end - eq - 1); } pos = end + 1; } ESP_LOGI(TAG, "zeroconf %s from \"%s\"", query["action"].c_str(), query["userName"].c_str()); 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"); return httpd_resp_send(req, ok, sizeof(ok) - 1); } /* ----- session ----- */ static const char *event_name(cspot::SpircHandler::EventType t) { using E = cspot::SpircHandler::EventType; switch (t) { case E::PLAY_PAUSE: return "PLAY_PAUSE"; case E::VOLUME: return "VOLUME"; case E::TRACK_INFO: return "TRACK_INFO"; case E::DISC: return "DISC"; case E::NEXT: return "NEXT"; case E::PREV: return "PREV"; case E::SEEK: return "SEEK"; case E::DEPLETED: return "DEPLETED"; case E::FLUSH: return "FLUSH"; case E::PLAYBACK_START: return "PLAYBACK_START"; } return "?"; } static void session(void) { cJSON *src = cfg_get("source"); std::string client_id = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_id")->valuestring; std::string client_secret = cJSON_GetObjectItemCaseSensitive(src, "spotify_client_secret")->valuestring; int bitrate = (int)cJSON_GetObjectItemCaseSensitive(src, "spotify_bitrate")->valuedouble; cJSON_Delete(src); 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; // createFromBlob() starts at volume 0, which the app would show; report what we actually play. ctx->config.volume = s_volume; s_state = "connecting"; ctx->session->connectWithRandomAp(); 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)"); return; } ctx->session->startTask(); auto handler = std::make_shared(ctx); handler->subscribeToMercury(); // PCM (44.1 kHz stereo s16) to the player; returning fewer bytes makes cspot retry the rest, // so the player's blocking write paces decoding to playback speed. handler->getTrackPlayer()->setDataCallback([](uint8_t *data, size_t len, std::string_view id) -> size_t { if (s_written) { // First data of a new track: it becomes audible when playback reaches this position. std::lock_guard lock(s_bound_mutex); if (id != s_last_id) { s_last_id = std::string(id); s_bounds.push_back({ s_written(), s_last_id }); } } size_t frames = len / 4; size_t taken = s_pcm_cb ? s_pcm_cb(reinterpret_cast(data), frames) : frames; s_pcm_bytes += taken * 4; return taken * 4; }); handler->setEventHandler([](std::unique_ptr ev) { using E = cspot::SpircHandler::EventType; switch (ev->eventType) { case E::TRACK_INFO: { auto &ti = std::get(ev->data); ESP_LOGI(TAG, "track: %s - %s (%lu ms)", ti.artist.c_str(), ti.name.c_str(), (unsigned long)ti.duration); std::lock_guard lock(s_track_mutex); s_track = ti; return; } case E::PLAY_PAUSE: if (s_event_cb) { s_event_cb(std::get(ev->data) ? SPOTIFY_EV_PAUSE : SPOTIFY_EV_PLAY, 0); } break; case E::FLUSH: case E::SEEK: case E::PLAYBACK_START: // The output buffer is dropped: pending boundaries are void. A new load (PLAYBACK_START) // gets a new boundary with its first data; a seek stays within the same track. clear_boundaries(ev->eventType == E::PLAYBACK_START); s_depleted = false; if (s_event_cb) { s_event_cb(SPOTIFY_EV_FLUSH, 0); if (ev->eventType == E::PLAYBACK_START) { s_event_cb(SPOTIFY_EV_PLAY, 0); } } break; case E::VOLUME: s_volume = std::get(ev->data); // exact value from the app, no echo back if (s_event_cb) { s_event_cb(SPOTIFY_EV_VOLUME, std::get(ev->data)); } break; case E::DEPLETED: s_depleted = true; // queue done: tell Spotify once the buffer has played out break; default: break; } ESP_LOGI(TAG, "event %s", event_name(ev->eventType)); }); s_handler = handler; s_state = "connected"; ESP_LOGI(TAG, "connected as %s", ctx->config.username.c_str()); clear_boundaries(true); s_depleted = false; { std::lock_guard lock(s_cmd_mutex); s_cmds.clear(); s_cmd_done = s_cmd_queued; } while (!s_stop) { ctx->session->handlePacket(); // waits at most 200 ms run_commands(handler); check_playback(handler); } // Stops the queue and player tasks (waits for them), then closes the connection. s_handler.reset(); { std::lock_guard lock(s_track_mutex); s_track = {}; } handler->disconnect(); ESP_LOGI(TAG, "session ended"); } static void spotify_task(void *arg) { while (true) { 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; s_in_session = true; try { session(); } catch (const std::exception &e) { s_state = "error"; ESP_LOGE(TAG, "session ended: %s", e.what()); } s_handler.reset(); s_in_session = false; vTaskDelay(pdMS_TO_TICKS(2000)); } } 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 lock(s_mutex); s_blob = std::make_shared(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, spotify_pos_cb_t written, spotify_pos_cb_t played) { s_pcm_cb = pcm; s_event_cb = event; s_written = written; s_played = played; if (!bell::bellGlobalLogger) { bell::bellGlobalLogger = new EspLogger(); } 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); if (xTaskCreate(spotify_task, "spotify", 32 * 1024, nullptr, 5, nullptr) != pdPASS) { return ESP_ERR_NO_MEM; } spotify_apply(); return ESP_OK; }