Files
aes67-ESP32-P4/components/spotify/spotify.cpp
T
bsncubed 8485ecb5b7 Player: transport commands on /api/player
play/pause/toggle/stop/next/prev. Spotify commands run on the session task
(same thread as cspot's frame handling) and the POST waits until they ran,
so it answers the new state. HLS pause stops fetching and resume rejoins at
the live edge; next/prev on HLS and anything on tone/off return 409.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 22:59:14 +10:00

545 lines
19 KiB
C++

#include "spotify.h"
#include <atomic>
#include <cstring>
#include <exception>
#include <deque>
#include <map>
#include <memory>
#include <mutex>
#include <string>
#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<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<bool> s_in_session{false}; // session() running (its tasks may exist)
static std::atomic<int> s_volume{65535}; // player volume in Spotify units (0..65535)
static std::atomic<const char *> s_state{"disabled"};
static std::atomic<uint64_t> s_pcm_bytes{0};
static std::shared_ptr<cspot::SpircHandler> 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<Boundary> s_bounds;
static std::string s_last_id; // track of the most recent data callback
static std::atomic<bool> s_depleted{false};
static std::mutex s_cmd_mutex;
static std::deque<spotify_cmd_t> 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<uint32_t> 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<std::mutex> 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<cspot::SpircHandler> &handler)
{
if (!s_written || !s_played) {
return;
}
uint32_t played = s_played();
std::string reached;
{
std::lock_guard<std::mutex> 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;
auto h = s_handler;
if (h) {
h->setRemoteVolume(v);
}
}
extern "C" bool spotify_command(spotify_cmd_t cmd)
{
if (!s_handler) {
return false;
}
uint32_t seq;
{
std::lock_guard<std::mutex> lock(s_cmd_mutex);
s_cmds.push_back(cmd);
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<cspot::SpircHandler> &handler)
{
std::deque<spotify_cmd_t> cmds;
uint32_t seq;
{
std::lock_guard<std::mutex> lock(s_cmd_mutex);
cmds.swap(s_cmds);
seq = s_cmd_queued;
}
for (auto cmd : 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;
}
ESP_LOGI(TAG, "command %d", (int)cmd);
}
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<std::mutex> 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<cspot::LoginBlob> blob()
{
std::lock_guard<std::mutex> 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<std::string, std::string> 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<cspot::SpircHandler>(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<std::mutex> 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<const int16_t *>(data), frames) : frames;
s_pcm_bytes += taken * 4;
return taken * 4;
});
handler->setEventHandler([](std::unique_ptr<cspot::SpircHandler::Event> ev) {
using E = cspot::SpircHandler::EventType;
switch (ev->eventType) {
case E::TRACK_INFO: {
auto &ti = std::get<cspot::TrackInfo>(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<std::mutex> lock(s_track_mutex);
s_track = ti;
return;
}
case E::PLAY_PAUSE:
if (s_event_cb) {
s_event_cb(std::get<bool>(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<int>(ev->data); // exact value from the app, no echo back
if (s_event_cb) {
s_event_cb(SPOTIFY_EV_VOLUME, std::get<int>(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<std::mutex> 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<std::mutex> 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<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, 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;
}