Files
aes67-ESP32-P4/components/spotify/spotify.cpp
T

576 lines
20 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 "esp_timer.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_want{false}; // last spotify_apply(): the player needs Spotify
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::mutex s_error_mutex;
static std::string s_error; // last session error, kept for the status
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;
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<Command> 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(s_want);
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<std::mutex> 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<std::mutex> 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<cspot::SpircHandler> &handler)
{
std::deque<Command> cmds;
uint32_t seq;
{
std::lock_guard<std::mutex> 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<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();
}
bool spotify_last_error(char *out, size_t len)
{
std::lock_guard<std::mutex> lock(s_error_mutex);
snprintf(out, len, "%s", s_error.c_str());
return !s_error.empty();
}
/* ----- 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());
std::lock_guard<std::mutex> lock(s_error_mutex);
s_error = std::string(e.what()) + " (at " + std::to_string(esp_timer_get_time() / 1000000) + " s)";
}
s_handler.reset();
s_in_session = false;
vTaskDelay(pdMS_TO_TICKS(2000));
}
}
extern "C" void spotify_apply(bool enable)
{
s_want = enable;
cJSON *src = cfg_get("source");
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 = enable && 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 && enable) {
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;
}
return ESP_OK; // the player enables it (spotify_apply)
}