diff --git a/Spotify-API-Call/Spotify-API-Call.ino b/Spotify-API-Call/Spotify-API-Call.ino new file mode 100644 index 0000000..93c393a --- /dev/null +++ b/Spotify-API-Call/Spotify-API-Call.ino @@ -0,0 +1,66 @@ +#include +#include +#include +#include // Wichtig für die Verarbeitung + +// Deine Daten +char ssid[] = "Wayne Manor"; +char password[] = "einfach10zeichen!"; +char clientId[] = "510f19fec68e42f39aad137b7666433f"; // Von deinem Screenshot +char clientSecret[] = "f13ea80ae8d641a0a449e84cbac7efde"; // Klicke auf "View client secret" im Dashboard +char refreshToken[] = "AQB5Orf0ymkJabrduB8uxYgtrwoUrrY6Y5upjoiyyQm7pklhFSzWuqX-fdLUZprQjpZ17seJja0wuHYM0bGPCUas22jcla-evEqu6PhtJkLRealUn8L9w2D8TZlijxfON0I"; +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, refreshToken); + +unsigned long delayBetweenRequests = 5000; // 5 Sekunden +unsigned long lastTimeChecked; + +// WICHTIG: Der Typ heißt "CurrentlyPlaying" und liegt im Namespace "SpotifyArduino" +void printSongDetails(CurrentlyPlaying currentlyPlaying) { + Serial.println("--------- NEUER SONG ---------"); + Serial.print("Titel: "); + Serial.println(currentlyPlaying.trackName); + Serial.print("Interpret: "); + Serial.println(currentlyPlaying.artists[0].artistName); + Serial.print("Album: "); + Serial.println(currentlyPlaying.albumName); + Serial.println("------------------------------"); +} + +void setup() { + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + while (WiFi.status() != WL_CONNECTED) { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.println("WiFi verbunden!"); + + // Erlaubt HTTPS ohne Fingerprint-Check + client.setInsecure(); +} + +void loop() { + if (millis() > lastTimeChecked + delayBetweenRequests) { + Serial.println("Frage Spotify ab..."); + + // Die Library nutzt intern den Typ "CurrentlyPlaying" + int status = spotify.getCurrentlyPlaying(printSongDetails, "DE"); + + if (status == 200) { + // Alles okay, Song wird in printSongDetails ausgegeben + } else if (status == 204) { + Serial.println("Nichts wird abgespielt (oder Spotify ist im Private Session Mode)."); + } else { + Serial.print("Fehler beim Abruf. Status Code: "); + Serial.println(status); + } + + lastTimeChecked = millis(); + } +} \ No newline at end of file diff --git a/Spotify-API-OLED/Spotify-API-OLED.ino b/Spotify-API-OLED/Spotify-API-OLED.ino new file mode 100644 index 0000000..5001328 --- /dev/null +++ b/Spotify-API-OLED/Spotify-API-OLED.ino @@ -0,0 +1,104 @@ +#include +#include +#include +#include +#include +#include +#include + +// --- KONFIGURATION --- +#define SCREEN_WIDTH 128 +#define SCREEN_HEIGHT 64 +#define SCREEN_ADDRESS 0x3C + +const char* ssid = "Wayne Manor"; +const char* password = "einfach10zeichen!"; + +// Spotify Credentials +char clientId[] = "510f19fec68e42f39aad137b7666433f"; +char clientSecret[] = "f13ea80ae8d641a0a449e84cbac7efde"; +char refreshToken[] = "AQB5Orf0ymkJabrduB8uxYgtrwoUrrY6Y5upjoiyyQm7pklhFSzWuqX-fdLUZprQjpZ17seJja0wuHYM0bGPCUas22jcla-evEqu6PhtJkLRealUn8L9w2D8TZlijxfON0I"; + +Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1); +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, refreshToken); + +// Globale Variablen +char globalScrollText[256] = "Warte auf Spotify..."; +int16_t scrollPos = SCREEN_WIDTH; +unsigned long lastSpotifyUpdate = 0; +const unsigned long spotifyInterval = 30000; // Alle 5 Sek. prüfen + +// Hilfsvariable zur Berechnung der Textbreite +int16_t textWidth = 0; + +void printSongDetails(CurrentlyPlaying currentlyPlaying) { + String track = String(currentlyPlaying.trackName); + String artist = String(currentlyPlaying.artists[0].artistName); + snprintf(globalScrollText, sizeof(globalScrollText), "+++ %s - by %s +++ ", track.c_str(), artist.c_str()); + // Breite neu berechnen (TextSize 2 -> 12 Pixel pro Zeichen) + textWidth = strlen(globalScrollText) * 12; +} + +void updateDisplay() { + display.clearDisplay(); + display.setTextColor(SSD1306_WHITE); + display.setTextWrap(false); + + // Laufschrift anzeigen + display.setTextSize(2); + display.setCursor(scrollPos, 20); // Mittig platziert + display.print(globalScrollText); + display.display(); + + // Scrollen + scrollPos -= 3; // Erhöhe für schnelleres Scrollen + if (scrollPos < -textWidth && textWidth > 0) { + scrollPos = SCREEN_WIDTH; + } +} + +void setup() { + Serial.begin(115200); + + if(!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) { + for(;;); + } + + display.clearDisplay(); + display.setTextSize(1); + display.setCursor(0,10); + display.print("WiFi Connecting..."); + display.display(); + + WiFi.begin(ssid, password); + while (WiFi.status() != WL_CONNECTED) { + delay(500); + } + + client.setInsecure(); + + display.clearDisplay(); + display.setCursor(0,10); + display.print("Spotify Online"); + display.display(); + delay(1000); +} + +void loop() { + // Spotify Abfrage + if (millis() - lastSpotifyUpdate > spotifyInterval) { + // Dieser Aufruf blockiert leider kurzzeitig die CPU + int status = spotify.getCurrentlyPlaying(printSongDetails, "DE"); + if (status == 204) { + snprintf(globalScrollText, sizeof(globalScrollText), "Keine Musik - Spotify Standby... "); + textWidth = strlen(globalScrollText) * 12; + } + lastSpotifyUpdate = millis(); + } + + // Display aktualisieren + updateDisplay(); + + delay(20); +} \ No newline at end of file diff --git a/libraries/SpotifyArduino/.gitignore b/libraries/SpotifyArduino/.gitignore new file mode 100644 index 0000000..b9f3806 --- /dev/null +++ b/libraries/SpotifyArduino/.gitignore @@ -0,0 +1,2 @@ +.pio +.vscode diff --git a/libraries/SpotifyArduino/.travis.yml b/libraries/SpotifyArduino/.travis.yml new file mode 100644 index 0000000..b567aaa --- /dev/null +++ b/libraries/SpotifyArduino/.travis.yml @@ -0,0 +1,50 @@ +language: python +python: + - "3.7" + +# Cache PlatformIO packages using Travis CI container-based infrastructure +cache: + directories: + - "~/.platformio" + +env: + # ESP8266 + - SCRIPT=platformioSingle EXAMPLE_NAME=getCurrentlyPlaying EXAMPLE_FOLDER=/ BOARD=d1_mini + - SCRIPT=platformioSingle EXAMPLE_NAME=getRefreshToken EXAMPLE_FOLDER=/ BOARD=d1_mini + - SCRIPT=platformioSingle EXAMPLE_NAME=playAdvanced EXAMPLE_FOLDER=/ BOARD=d1_mini + - SCRIPT=platformioSingle EXAMPLE_NAME=playerControls EXAMPLE_FOLDER=/ BOARD=d1_mini + - SCRIPT=platformioSingle EXAMPLE_NAME=playerDetails EXAMPLE_FOLDER=/ BOARD=d1_mini + - SCRIPT=platformioSingle EXAMPLE_NAME=getDevices EXAMPLE_FOLDER=/ BOARD=d1_mini + + # ESP32 + # - SCRIPT=platformioSingle EXAMPLE_NAME=albumArtMatrix EXAMPLE_FOLDER=/displayAlbumArt/ BOARDTYPE=ESP32 BOARD=esp32dev + - SCRIPT=platformioSingle EXAMPLE_NAME=getCurrentlyPlaying EXAMPLE_FOLDER=/ BOARD=esp32dev + - SCRIPT=platformioSingle EXAMPLE_NAME=getRefreshToken EXAMPLE_FOLDER=/ BOARD=esp32dev + - SCRIPT=platformioSingle EXAMPLE_NAME=playAdvanced EXAMPLE_FOLDER=/ BOARD=esp32dev + - SCRIPT=platformioSingle EXAMPLE_NAME=playerControls EXAMPLE_FOLDER=/ BOARD=esp32dev + - SCRIPT=platformioSingle EXAMPLE_NAME=playerDetails EXAMPLE_FOLDER=/ BOARD=esp32dev + - SCRIPT=platformioSingle EXAMPLE_NAME=getDevices EXAMPLE_FOLDER=/ BOARD=esp32dev + +before_install: + +install: + - pip install -U platformio + - platformio update + # + # Libraries from PlatformIO Library Registry: + # + # https://platformio.org/lib/show/64/ArduinoJson + # https://platformio.org/lib/show/3577/ESP32%2064x32%20LED%20MATRIX%20HUB75%20DMA%20Display + # https://platformio.org/lib/show/13/Adafruit%20GFX%20Library + # https://platformio.org/lib/show/6906/TJpg_Decoder + # https://platformio.org/lib/show/6214/Adafruit%20BusIO + - platformio lib -g install 64 3577 13 6906 6214 + +before_script: + # + +script: + - scripts/travis/$SCRIPT.sh + +before_cache: + # - rm -rf ~/.platformio diff --git a/libraries/SpotifyArduino/FUNDING.yml b/libraries/SpotifyArduino/FUNDING.yml new file mode 100644 index 0000000..52b72bd --- /dev/null +++ b/libraries/SpotifyArduino/FUNDING.yml @@ -0,0 +1 @@ +github: [witnessmenow] \ No newline at end of file diff --git a/libraries/SpotifyArduino/LICENSE b/libraries/SpotifyArduino/LICENSE new file mode 100644 index 0000000..2b72617 --- /dev/null +++ b/libraries/SpotifyArduino/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Brian Lough + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/libraries/SpotifyArduino/README.md b/libraries/SpotifyArduino/README.md new file mode 100644 index 0000000..bbcebfa --- /dev/null +++ b/libraries/SpotifyArduino/README.md @@ -0,0 +1,113 @@ +# spotify-api-arduino + +![Travis CI status](https://api.travis-ci.org/witnessmenow/arduino-spotify-api.svg?branch=master) +![License](https://img.shields.io/github/license/witnessmenow/spotify-api-arduino) +![Release stable](https://badgen.net/github/release/witnessmenow/spotify-api-arduino/stable) +Arduino library for integrating with a subset of the [Spotify Web-API](https://developer.spotify.com/documentation/web-api/reference/) (Does not play music) + +**Work in progress library - expect changes!** + +## Supported Boards: + +### ESP32 + +Working well + +### ESP8266 + +Working well + +### Other boards - Arduino Wifi Nina (Nano IOT etc) + +Should in theory work, but I have no tested it on them and will not be able to provide support for them. + +## Help support what I do! + +I have put a lot of effort into creating Arduino libraries that I hope people can make use of. [If you enjoy my work, please consider becoming a Github sponsor!](https://github.com/sponsors/witnessmenow/) + +## Library Features: + +The Library supports the following features: + +- Get Authentication Tokens +- Getting your currently playing track +- Player Controls: + - Next + - Previous + - Seek + - Play (basic version, basically resumes a paused track) + - Play Advanced (play given song, album, artist) + - Pause + - Set Volume (doesn't seem to work on my phone, works on desktop though) + - Set Repeat Modes + - Toggle Shuffle +- Get Devices +- Search Spotify Library + +### What needs to be added: + +- Better instructions for how to set up your refresh token. +- Example where refresh token and full operation are handled in same sketch. + +## Setup Instructions + +### Spotify Account + +- Sign into the [Spotify Developer page](https://developer.spotify.com/dashboard/login) +- Create a new application. (name it whatever you want) +- You will need to use the "client ID" and "client secret" from this page in your sketches +- You will also need to add a callback URI for authentication process by clicking "Edit Settings", what URI to add will be mentioned in further instructions + +### Getting Your Refresh Token + +Spotify's Authentication flow requires a webserver to complete, but it's only needed once to get your refresh token. Your refresh token can then be used in all future sketches to authenticate. + +Because the webserver is only needed once, I decided to seperate the logic for getting the Refresh token to it's own example. + +Follow the instructions in the [getRefreshToken example](examples/getRefreshToken/getRefreshToken.ino) to get your token. + +Note: Once you have a refresh token, you can use it on either platform in your sketches, it is not tied to any particular device. + +### Running + +Take one of the included examples and update it with your WiFi creds, Client ID, Client Secret and the refresh token you just generated. + +### Scopes + +By default the getRefreshToken examples will include the required scopes, but if you want change them the following info might be useful. + +put a `%20` between the ones you need. + +| Feature | Required Scope | +| ------------------------- | -------------------------- | +| Current Playing Song Info | user-read-playback-state | +| Player Controls | user-modify-playback-state | + +## Installation + +Download zip from Github and install to the Arduino IDE using that. + +#### Dependancies + +- V6 of Arduino JSON - can be installed through the Arduino Library manager. + +## Compile flag configuration + +There are some flags that you can set in the `SpotifyArduino.h` that can help with debugging + +``` + +#define SPOTIFY_DEBUG 1 +// Enables extra debug messages on the serial. +// Will be disabled by default when library is released. +// NOTE: Do not use this option on live-streams, it will reveal your private tokens! + +#define SPOTIFY_SERIAL_OUTPUT 1 +// Comment out if you want to disable any serial output from this library +// (also comment out DEBUG and PRINT_JSON_PARSE) + +//#define SPOTIFY_PRINT_JSON_PARSE 1 +// Prints the JSON received to serial (only use for debugging as it will be slow) +// Requires the installation of ArduinoStreamUtils (https://github.com/bblanchon/ArduinoStreamUtils) + +``` diff --git a/libraries/SpotifyArduino/examples/displayAlbumArt/tftAlbumArt/tftDisplayImage.ino b/libraries/SpotifyArduino/examples/displayAlbumArt/tftAlbumArt/tftDisplayImage.ino new file mode 100644 index 0000000..1bf3456 --- /dev/null +++ b/libraries/SpotifyArduino/examples/displayAlbumArt/tftAlbumArt/tftDisplayImage.ino @@ -0,0 +1,363 @@ +/******************************************************************* + Displays Album Art on an 128 x 160 display. ESP32 Only + + There is two approaches to this demoed in this example + - "displayImage" uses a memory buffer, it should be the fastest but possible uses the most memory. + - "displayImageUsingFile" uses a File reference + + All references to SPIFFS are only required for the "displayImageUsingFile" path. + + This example could easily be adapted to any TFT + based screen. + + The library for the display will need to be modified to work + with a 128x160 display: + https://github.com/Bodmer/TFT_eSPI + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Parts: + ESP32 D1 Mini stlye Dev board* - http://s.click.aliexpress.com/e/C6ds4my + ESP32 I2S Matrix Shield (From my Tindie) = https://www.tindie.com/products/brianlough/esp32-i2s-matrix-shield/ + 128x160 LCD Screen - shorturl.at/elKQV + + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- +#include +#include + +#include +#include "SPIFFS.h" + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +#include + +#include +#include + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +// file name for where to save the image. +#define ALBUM_ART "/album.jpg" + +// so we can compare and not download the same image if we already have it. +String lastAlbumArtUrl; + +// Variable to hold image info +SpotifyImage smallestImage; + +// so we can store the song name and artist name +char *songName; +char *songArtist; + +WiFiClientSecure client; +SpotifyArduino spotify(client, client_id, client_secret, SPOTIFY_REFRESH_TOKEN); + +// You might want to make this much smaller, so it will update responsively + +unsigned long delayBetweenRequests = 30000; // Time between requests (30 seconds) +unsigned long requestDueTime; // time when request due + +TFT_eSPI tft = TFT_eSPI(); + +// This next function will be called during decoding of the jpeg file to +// render each block to the Matrix. If you use a different display +// you will need to adapt this function to suit. +bool displayOutput(int16_t x, int16_t y, uint16_t w, uint16_t h, uint16_t *bitmap) +{ + // Stop further decoding as image is running off bottom of screen + if (y >= tft.height()) + return 0; + + tft.pushImage(x, y, w, h, bitmap); + + // Return 1 to decode next block + return 1; +} + +void setup() +{ + Serial.begin(115200); + + // Initialise SPIFFS, if this fails try .begin(true) + // NOTE: I believe this formats it though it will erase everything on + // spiffs already! In this example that is not a problem. + // I have found once I used the true flag once, I could use it + // without the true flag after that. + + if (!SPIFFS.begin()) + { + Serial.println("SPIFFS initialisation failed!"); + while (1) + yield(); // Stay here twiddling thumbs waiting + } + Serial.println("\r\nInitialisation done."); + + // Start the tft display and set it to black + tft.init(); + tft.fillScreen(TFT_BLACK); + + // The jpeg image can be scaled by a factor of 1, 2, 4, or 8 + TJpgDec.setJpgScale(4); + + // The decoder must be given the exact name of the rendering function above + TJpgDec.setCallback(displayOutput); + + // The byte order can be swapped (set true for TFT_eSPI) + TJpgDec.setSwapBytes(true); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + client.setCACert(spotify_server_cert); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + } +} + +int displayImageUsingFile(char *albumArtUrl) +{ + + // In this example I reuse the same filename + // over and over, maybe saving the art using + // the album URI as the name would be better + // as you could save having to download them each + // time, but this seems to work fine. + if (SPIFFS.exists(ALBUM_ART) == true) + { + Serial.println("Removing existing image"); + SPIFFS.remove(ALBUM_ART); + } + + fs::File f = SPIFFS.open(ALBUM_ART, "w+"); + if (!f) + { + Serial.println("file open failed"); + return -1; + } + + // Spotify uses a different cert for the Image server, so we need to swap to that for the call + client.setCACert(spotify_image_server_cert); + bool gotImage = spotify.getImage(albumArtUrl, &f); + + // Swapping back to the main spotify cert + client.setCACert(spotify_server_cert); + + // Make sure to close the file! + f.close(); + + if (gotImage) + { + return TJpgDec.drawFsJpg(0, 0, ALBUM_ART); + } + else + { + return -2; + } +} + +int displayImage(char *albumArtUrl) +{ + + uint8_t *imageFile; // pointer that the library will store the image at (uses malloc) + int imageSize; // library will update the size of the image + bool gotImage = spotify.getImage(albumArtUrl, &imageFile, &imageSize); + + if (gotImage) + { + Serial.print("Got Image"); + delay(1); + int jpegStatus = TJpgDec.drawJpg(28, 40, imageFile, imageSize); + free(imageFile); // Make sure to free the memory! + return jpegStatus; + } + else + { + return -2; + } +} + +void printCurrentlyPlayingToSerial(CurrentlyPlaying currentlyPlaying) +{ + // Use the details in this method or if you want to store them + // make sure you copy them (using something like strncpy) + // const char* artist = + + // Clear the Text every time a new song is created + tft.fillRect(0, 120, 128, 130, TFT_BLACK); + Serial.println("--------- Currently Playing ---------"); + + Serial.print("Is Playing: "); + if (currentlyPlaying.isPlaying) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Track: "); + Serial.println(currentlyPlaying.trackName); + // Save the song name to a variable + songName = const_cast(currentlyPlaying.trackName); + drawMessage(0, 120, songName); + Serial.print("Track URI: "); + Serial.println(currentlyPlaying.trackUri); + Serial.println(); + + Serial.println("Artists: "); + for (int i = 0; i < currentlyPlaying.numArtists; i++) + { + Serial.print("Name: "); + // Save the song artist name to a variable + Serial.println(currentlyPlaying.artists[i].artistName); + songArtist = const_cast(currentlyPlaying.artists[0].artistName); + drawMessage(0, 130, songArtist); + Serial.print("Artist URI: "); + Serial.println(currentlyPlaying.artists[i].artistUri); + Serial.println(); + } + + Serial.print("Album: "); + Serial.println(currentlyPlaying.albumName); + Serial.print("Album URI: "); + Serial.println(currentlyPlaying.albumUri); + Serial.println(); + + long progress = currentlyPlaying.progressMs; // duration passed in the song + long duration = currentlyPlaying.durationMs; // Length of Song + Serial.print("Elapsed time of song (ms): "); + Serial.print(progress); + Serial.print(" of "); + Serial.println(duration); + Serial.println(); + + float percentage = ((float)progress / (float)duration) * 100; + int clampedPercentage = (int)percentage; + Serial.print("<"); + for (int j = 0; j < 50; j++) + { + if (clampedPercentage >= (j * 2)) + { + Serial.print("="); + } + else + { + Serial.print("-"); + } + } + Serial.println(">"); + Serial.println(); + + // will be in order of widest to narrowest + // currentlyPlaying.numImages is the number of images that + // are stored + + for (int i = 0; i < currentlyPlaying.numImages; i++) + { + // Save the second album image into the smallestImage Variable above. + smallestImage = currentlyPlaying.albumImages[1]; + Serial.println("------------------------"); + Serial.print("Album Image: "); + Serial.println(currentlyPlaying.albumImages[i].url); + Serial.print("Dimensions: "); + Serial.print(currentlyPlaying.albumImages[i].width); + Serial.print(" x "); + Serial.print(currentlyPlaying.albumImages[i].height); + Serial.println(); + } + Serial.println("------------------------"); +} + +void loop() +{ + if (millis() > requestDueTime) + { + Serial.print("Free Heap: "); + Serial.println(ESP.getFreeHeap()); + + Serial.println("getting currently playing song:"); + // Check if music is playing currently on the account. + int status = spotify.getCurrentlyPlaying(printCurrentlyPlayingToSerial, SPOTIFY_MARKET); + if (status == 200) + { + Serial.println("Successfully got currently playing"); + String newAlbum = String(smallestImage.url); + if (newAlbum != lastAlbumArtUrl) + { + Serial.println("Updating Art"); + char *my_url = const_cast(smallestImage.url); + int displayImageResult = displayImage(my_url); + + if (displayImageResult == 0) + { + lastAlbumArtUrl = newAlbum; + } + else + { + Serial.print("failed to display image: "); + Serial.println(displayImageResult); + } + } + else if (status == 204) + { + Serial.println("Doesn't seem to be anything playing"); + } + else + { + Serial.print("Error: "); + Serial.println(status); + } + + requestDueTime = millis() + delayBetweenRequests; + } + } +} + +// Method to draw messages at a certain point on a TFT Display. +void drawMessage(int x, int y, char *message) +{ + tft.setTextColor(TFT_WHITE); + tft.drawString(message, x, y); +} \ No newline at end of file diff --git a/libraries/SpotifyArduino/examples/getCurrentlyPlaying/getCurrentlyPlaying.ino b/libraries/SpotifyArduino/examples/getCurrentlyPlaying/getCurrentlyPlaying.ino new file mode 100644 index 0000000..fa1d38d --- /dev/null +++ b/libraries/SpotifyArduino/examples/getCurrentlyPlaying/getCurrentlyPlaying.ino @@ -0,0 +1,234 @@ +/******************************************************************* + Prints your currently playing track on spotify to the + serial monitor using an ES32 or ESP8266 + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#elif defined(ESP32) +#include +#endif + +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +//------- ---------------------- ------ + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, SPOTIFY_REFRESH_TOKEN); + +unsigned long delayBetweenRequests = 60000; // Time between requests (1 minute) +unsigned long requestDueTime; //time when request due + +void setup() +{ + + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + } +} + +void printCurrentlyPlayingToSerial(CurrentlyPlaying currentlyPlaying) +{ + // Use the details in this method or if you want to store them + // make sure you copy them (using something like strncpy) + // const char* artist = + + Serial.println("--------- Currently Playing ---------"); + + Serial.print("Is Playing: "); + if (currentlyPlaying.isPlaying) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Track: "); + Serial.println(currentlyPlaying.trackName); + Serial.print("Track URI: "); + Serial.println(currentlyPlaying.trackUri); + Serial.println(); + + Serial.println("Artists: "); + for (int i = 0; i < currentlyPlaying.numArtists; i++) + { + Serial.print("Name: "); + Serial.println(currentlyPlaying.artists[i].artistName); + Serial.print("Artist URI: "); + Serial.println(currentlyPlaying.artists[i].artistUri); + Serial.println(); + } + + Serial.print("Album: "); + Serial.println(currentlyPlaying.albumName); + Serial.print("Album URI: "); + Serial.println(currentlyPlaying.albumUri); + Serial.println(); + + if (currentlyPlaying.contextUri != NULL) + { + Serial.print("Context URI: "); + Serial.println(currentlyPlaying.contextUri); + Serial.println(); + } + + long progress = currentlyPlaying.progressMs; // duration passed in the song + long duration = currentlyPlaying.durationMs; // Length of Song + Serial.print("Elapsed time of song (ms): "); + Serial.print(progress); + Serial.print(" of "); + Serial.println(duration); + Serial.println(); + + float percentage = ((float)progress / (float)duration) * 100; + int clampedPercentage = (int)percentage; + Serial.print("<"); + for (int j = 0; j < 50; j++) + { + if (clampedPercentage >= (j * 2)) + { + Serial.print("="); + } + else + { + Serial.print("-"); + } + } + Serial.println(">"); + Serial.println(); + + // will be in order of widest to narrowest + // currentlyPlaying.numImages is the number of images that + // are stored + for (int i = 0; i < currentlyPlaying.numImages; i++) + { + Serial.println("------------------------"); + Serial.print("Album Image: "); + Serial.println(currentlyPlaying.albumImages[i].url); + Serial.print("Dimensions: "); + Serial.print(currentlyPlaying.albumImages[i].width); + Serial.print(" x "); + Serial.print(currentlyPlaying.albumImages[i].height); + Serial.println(); + } + Serial.println("------------------------"); +} + +void loop() +{ + if (millis() > requestDueTime) + { + Serial.print("Free Heap: "); + Serial.println(ESP.getFreeHeap()); + + Serial.println("getting currently playing song:"); + // Market can be excluded if you want e.g. spotify.getCurrentlyPlaying() + int status = spotify.getCurrentlyPlaying(printCurrentlyPlayingToSerial, SPOTIFY_MARKET); + if (status == 200) + { + Serial.println("Successfully got currently playing"); + } + else if (status == 204) + { + Serial.println("Doesn't seem to be anything playing"); + } + else + { + Serial.print("Error: "); + Serial.println(status); + } + requestDueTime = millis() + delayBetweenRequests; + } +} diff --git a/libraries/SpotifyArduino/examples/getDevices/getDevices.ino b/libraries/SpotifyArduino/examples/getDevices/getDevices.ino new file mode 100644 index 0000000..7aa65f1 --- /dev/null +++ b/libraries/SpotifyArduino/examples/getDevices/getDevices.ino @@ -0,0 +1,252 @@ +/******************************************************************* + Prints info about the devices currently connected to your spotify account + and transfer playback between them. + (useful for switching between your phone to your PC for example) + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#elif defined(ESP32) +#include +#endif + +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +//------- ---------------------- ------ + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, SPOTIFY_REFRESH_TOKEN); + +unsigned long delayBetweenRequests = 60000; // Time between requests (1 minute) +unsigned long requestDueTime; //time when request due + +// This is potentially optional depending on how you want to do it, +// but we are going to store the important information the library returns here +struct SimpleDevice +{ + char name[SPOTIFY_DEVICE_NAME_CHAR_LENGTH]; + char id[SPOTIFY_DEVICE_ID_CHAR_LENGTH]; +}; + +#define MAX_DEVICES 6 + +SimpleDevice deviceList[MAX_DEVICES]; +int numberOfDevices = -1; + +void setup() +{ + + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + //spotify.getDevicesBufferSize = 3000; //can be adjusted, needs about 300 per device, defaults to 3000 + // Remember this is all devices returned by spotify, not just the max you want to return + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + } +} + +void printDeviceToSerial(SpotifyDevice device) +{ + + Serial.println("--------- Device Details ---------"); + + Serial.print("Device ID: "); + Serial.println(device.id); + + Serial.print("Device Name: "); + Serial.println(device.name); + + Serial.print("Device Type: "); + Serial.println(device.type); + + Serial.print("Is Active: "); + if (device.isActive) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Is Resticted: "); + if (device.isRestricted) + { + Serial.println("Yes, from API docs \"no Web API commands will be accepted by this device\""); + } + else + { + Serial.println("No"); + } + + Serial.print("Is Private Session: "); + if (device.isPrivateSession) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Volume Percent: "); + Serial.println(device.volumePercent); + + Serial.println("------------------------"); +} + +bool getDeviceCallback(SpotifyDevice device, int index, int numDevices) +{ + if (index == 0) + { + // This is a first device from this batch + // lets set the number of devices we got back + if (numDevices < MAX_DEVICES) + { + numberOfDevices = numDevices; + } + else + { + numberOfDevices = MAX_DEVICES; + } + } + + // We can't handle anymore than we can fit in our array + if (index < MAX_DEVICES) + { + printDeviceToSerial(device); + + strncpy(deviceList[index].name, device.name, sizeof(deviceList[index].name)); //DO NOT use deviceList[index].name = device.name, it won't work as you expect! + deviceList[index].name[sizeof(deviceList[index].name) - 1] = '\0'; //ensures its null terminated + + strncpy(deviceList[index].id, device.id, sizeof(deviceList[index].id)); + deviceList[index].id[sizeof(deviceList[index].id) - 1] = '\0'; + + if (index == MAX_DEVICES - 1) + { + return false; //returning false stops it processing any more + } + else + { + return true; + } + } + + // We should never get here + return false; //returning false stops it processing any more +} + +void loop() +{ + if (millis() > requestDueTime) + { + Serial.print("Free Heap: "); + Serial.println(ESP.getFreeHeap()); + + Serial.println("Getting devices:"); + int status = spotify.getDevices(getDeviceCallback); + if (status == 200) + { + Serial.println("Successfully got devices, tranfering playback between them"); + for (int i = 0; i < numberOfDevices; i++) + { + // You could do this transfer play back in the callback + // But this example is more to simulate grabbing the devices + // and having a UI to change between them + spotify.transferPlayback(deviceList[i].id, true); //true means to play after transfer + delay(5000); + } + } + + requestDueTime = millis() + delayBetweenRequests; + } +} diff --git a/libraries/SpotifyArduino/examples/getRefreshToken/getRefreshToken.ino b/libraries/SpotifyArduino/examples/getRefreshToken/getRefreshToken.ino new file mode 100644 index 0000000..88b0b92 --- /dev/null +++ b/libraries/SpotifyArduino/examples/getRefreshToken/getRefreshToken.ino @@ -0,0 +1,243 @@ +/******************************************************************* + Get Refresh Token from spotify, this is needed for the other + examples. + + Instructions: + + - Put in your Wifi details, Client ID, Client secret and flash to the board + - Do one of the following + + --- IF USING IP (ESP32 MDNS does not work for me) + + - Get the Ip Address from the serial monitor + - Add the following to Redirect URI on your Spotify app "http://[ESP_IP]/callback/" + e.g. "http://192.168.1.20/callback/" (don't forget the last "/") + - Open browser to esp using the IP + + --- IF USING MDNS + + - Search for "#define USE_IP_ADDRESS" and comment it out + - Add the following to Redirect URI on your Spotify app "http://arduino.local/callback/" + (don't forget the last "/") + - Open browser to esp: http://arduino.local + + ----------- + + - Click the link on the webpage + - The Refresh Token will be printed to screen, use this + for SPOTIFY_REFRESH_TOKEN in other examples. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#include +#include +#elif defined(ESP32) +#include +#include +#include +#endif + +#include +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +char scope[] = "user-read-playback-state%20user-modify-playback-state"; + +#define USE_IP_ADDRESS 1 //comment this out if you want to use MDNS + +#ifdef USE_IP_ADDRESS +char callbackURItemplate[] = "%s%s%s"; +char callbackURIProtocol[] = "http%3A%2F%2F"; // "http://" +char callbackURIAddress[] = "%2Fcallback%2F"; // "/callback/" +char callbackURI[100]; +#else +char callbackURI[] = "http%3A%2F%2Farduino.local%2Fcallback%2F"; +#endif + +//------- ---------------------- ------ + +#if defined(ESP8266) +ESP8266WebServer server(80); +#elif defined(ESP32) +WebServer server(80); +#endif + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret); + +const char *webpageTemplate = + R"( + + + + + + + + + + + +)"; + +void handleRoot() +{ + char webpage[800]; + sprintf(webpage, webpageTemplate, clientId, callbackURI, scope); + server.send(200, "text/html", webpage); +} + +void handleCallback() +{ + String code = ""; + const char *refreshToken = NULL; + for (uint8_t i = 0; i < server.args(); i++) + { + if (server.argName(i) == "code") + { + code = server.arg(i); + refreshToken = spotify.requestAccessTokens(code.c_str(), callbackURI); + } + } + + if (refreshToken != NULL) + { + server.send(200, "text/plain", refreshToken); + } + else + { + server.send(404, "text/plain", "Failed to load token, check serial monitor"); + } +} + +void handleNotFound() +{ + String message = "File Not Found\n\n"; + message += "URI: "; + message += server.uri(); + message += "\nMethod: "; + message += (server.method() == HTTP_GET) ? "GET" : "POST"; + message += "\nArguments: "; + message += server.args(); + message += "\n"; + + for (uint8_t i = 0; i < server.args(); i++) + { + message += " " + server.argName(i) + ": " + server.arg(i) + "\n"; + } + + Serial.print(message); + server.send(404, "text/plain", message); +} + +void setup() +{ + + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + IPAddress ipAddress = WiFi.localIP(); + Serial.println(ipAddress); + + if (MDNS.begin("arduino")) + { + Serial.println("MDNS responder started"); + } + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + +#ifdef USE_IP_ADDRESS + // Building up callback URL using IP address. + sprintf(callbackURI, callbackURItemplate, callbackURIProtocol, ipAddress.toString().c_str(), callbackURIAddress); +#endif + + server.on("/", handleRoot); + server.on("/callback/", handleCallback); + server.onNotFound(handleNotFound); + server.begin(); + Serial.println("HTTP server started"); +} + +void loop() +{ +#if defined(ESP8266) + MDNS.update(); +#endif + + server.handleClient(); +} diff --git a/libraries/SpotifyArduino/examples/getSearchResults/getSearchResults.ino b/libraries/SpotifyArduino/examples/getSearchResults/getSearchResults.ino new file mode 100644 index 0000000..cf436e4 --- /dev/null +++ b/libraries/SpotifyArduino/examples/getSearchResults/getSearchResults.ino @@ -0,0 +1,194 @@ +/******************************************************************* + Prints song information from a Spotify search query to the + serial monitor using an ES32 or ESP8266 + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + + Search functionality by Jeremiah Ukwela + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#elif defined(ESP32) +#include +#endif + +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +//------- ---------------------- ------ + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, SPOTIFY_REFRESH_TOKEN); + +unsigned long delayBetweenRequests = 60000; // Time between requests (1 minute) +unsigned long requestDueTime; //time when request due + +#define MAX_RESULTS 2 + +void setup() +{ + + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + //spotify.searchDetailsBufferSize = 3000; //can be adjusted, needs about 500 per result, defaults to 3000 + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + } + + // If you get an out of memory error, you can increase the search buffer size, default is 3000 + // spotify.searchDetailsBufferSize = 3000; +} + +bool getResultsCallback(SearchResult result, int index, int numResults) +{ + + Serial.println("--------- Song Details ---------"); + + Serial.print("Song Index: "); + Serial.println(index); + + Serial.print("Song ID: "); + Serial.println(result.trackUri); + + Serial.print("Song Name: "); + Serial.println(result.trackName); + + Serial.print("Song Artists: "); + for (int i = 0; i < result.numArtists; i++){ + Serial.println(result.artists[i].artistName); + } + + Serial.print("Song Album: "); + Serial.println(result.albumName); + + Serial.print("Song Images (url): "); + for (int i = 0; i < result.numImages; i++){ + Serial.println(result.albumImages[i].url); + } + + Serial.println("-------------------------------"); + + return true; +} + +void loop() +{ + if (millis() > requestDueTime) + { + Serial.print("Free Heap: "); + Serial.println(ESP.getFreeHeap()); + + Serial.println("Making Search Request:"); + + String query = "/?q=artist:Toto&type=track&market=US&offset=1"; + + SearchResult results[MAX_RESULTS]; + int status = spotify.searchForSong(query, MAX_RESULTS, getResultsCallback, results); + //spotify.searchForSong(query, limit, callback, array); + + if (status == 200) + { + Serial.println("Successfully got results: printing information"); + for (int i = 0; i < MAX_RESULTS; i++) + { + //Plays 30 seconds of each song from the search + char body[100]; + sprintf(body, "{\"uris\" : [\"%s\"]}", results[i].trackUri); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } + delay(30*1e3); + } + } + + requestDueTime = millis() + delayBetweenRequests; + } +} diff --git a/libraries/SpotifyArduino/examples/playAdvanced/playAdvanced.ino b/libraries/SpotifyArduino/examples/playAdvanced/playAdvanced.ino new file mode 100644 index 0000000..5676ece --- /dev/null +++ b/libraries/SpotifyArduino/examples/playAdvanced/playAdvanced.ino @@ -0,0 +1,277 @@ +/******************************************************************* + Allow you to specify a track/artist/album to play. + + This is based on the request described here on the + spotify documentation: + https://developer.spotify.com/documentation/web-api/reference/player/start-a-users-playback/ + + This example shows some ways of invoking the above, but read the documentation + for full details. + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#elif defined(ESP32) +#include +#endif + +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +//------- ---------------------- ------ + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, SPOTIFY_REFRESH_TOKEN); + +void playSingleTrack() +{ + char sampleTrack[] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"; + char body[100]; + sprintf(body, "{\"uris\" : [\"%s\"]}", sampleTrack); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void playMultipleTracks() +{ + char sampleTrack1[] = "spotify:track:6vW1WpedCmV4gtOijSoQV3"; + char sampleTrack2[] = "spotify:track:4dJYjR2lM6SmYfLw2mnHvb"; + char sampleTrack3[] = "spotify:track:4uLU6hMCjMI75M1A2tKUQC"; + + char body[200]; + sprintf(body, "{\"uris\" : [\"%s\", \"%s\", \"%s\"]}", sampleTrack1, sampleTrack2, sampleTrack3); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void playAlbum() +{ + char sampleAlbum[] = "spotify:album:6N9PS4QXF1D0OWPk0Sxtb4"; + + char body[100]; + sprintf(body, "{\"context_uri\" : \"%s\"}", sampleAlbum); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void specifyTrackNumOfAlbum() +{ + char sampleAlbum[] = "spotify:album:2fPcSpVFVo1dXEvarNoFkB"; + // The position has an index of 0, so passing in 2 + // like this will actually play the 3rd song. + int trackNum = 2; + + char body[200]; + sprintf(body, "{\"context_uri\" : \"%s\", \"offset\": {\"position\": %d}}", sampleAlbum, trackNum); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void specifyTrackOfAlbum() +{ + char sampleAlbum[] = "spotify:album:2BLjT6yzDdKojUyc3Gi6y2"; + char trackOnAlbum[] = "spotify:track:25IZtuJS77yXPCXMhPa1ze"; + + char body[200]; + sprintf(body, "{\"context_uri\" : \"%s\", \"offset\": {\"uri\": \"%s\"}}", sampleAlbum, trackOnAlbum); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void playArtist() +{ + char sampleArtist[] = "spotify:artist:0gxyHStUsqpMadRV0Di1Qt"; + + char body[100]; + sprintf(body, "{\"context_uri\" : \"%s\"}", sampleArtist); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void playPlaylist() +{ + char samplePlaylist[] = "spotify:playlist:37i9dQZF1DZ06evO05tE88"; + + char body[100]; + sprintf(body, "{\"context_uri\" : \"%s\"}", samplePlaylist); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void specifyTrackNumOfPlaylist() +{ + char samplePlaylist[] = "spotify:playlist:37i9dQZF1DZ06evO05tE88"; + // The position has an index of 0, so passing in 31 + // like this will actually play the 32nd song. + int playlistTrackNum = 31; + + char body[200]; + sprintf(body, "{\"context_uri\" : \"%s\", \"offset\": {\"position\": %d}}", samplePlaylist, playlistTrackNum); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void specifyTrackOfPlaylist() +{ + char samplePlaylist[] = "spotify:playlist:37i9dQZF1DZ06evO05tE88"; + char trackOnPlaylist[] = "spotify:track:6vW1WpedCmV4gtOijSoQV3"; + + char body[200]; + sprintf(body, "{\"context_uri\" : \"%s\", \"offset\": {\"uri\": \"%s\"}}", samplePlaylist, trackOnPlaylist); + if (spotify.playAdvanced(body)) + { + Serial.println("sent!"); + } +} + +void setup() +{ + + Serial.begin(115200); + + // Set WiFi to station mode and disconnect from an AP if it was Previously + // connected + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + // Attempt to connect to Wifi network: + Serial.print("Connecting Wifi: "); + Serial.println(ssid); + WiFi.begin(ssid, password); + while (WiFi.status() != WL_CONNECTED) + { + Serial.print("."); + delay(500); + } + Serial.println(""); + Serial.println("WiFi connected"); + Serial.println("IP address: "); + IPAddress ip = WiFi.localIP(); + Serial.println(ip); + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + return; + } + + delay(100); + Serial.println("Playing Single Track"); + playSingleTrack(); + delay(10000); + Serial.println("Playing Multiple Tracks"); + playMultipleTracks(); + delay(10000); + Serial.println("Playing Album"); + playAlbum(); + delay(10000); + Serial.println("Playing track number on Album"); + specifyTrackNumOfAlbum(); + delay(10000); + Serial.println("Playing specific track on Album"); + specifyTrackOfAlbum(); + delay(10000); + Serial.println("Playing Artist"); + playArtist(); + delay(10000); + Serial.println("Playing Playlist"); + playPlaylist(); + delay(10000); + Serial.println("Playing track number on Playlist"); + specifyTrackNumOfPlaylist(); + delay(10000); + Serial.println("Playing specific track on Playlist"); + specifyTrackOfPlaylist(); +} + +// Example code is at end of setup +void loop() +{ +} diff --git a/libraries/SpotifyArduino/examples/playerControls/playerControls.ino b/libraries/SpotifyArduino/examples/playerControls/playerControls.ino new file mode 100644 index 0000000..adedeae --- /dev/null +++ b/libraries/SpotifyArduino/examples/playerControls/playerControls.ino @@ -0,0 +1,202 @@ +/******************************************************************* + Controls spotify player using an ESP32 or ESP8266 + + Supports: + - Next Track + - Previous Track + - Seek + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#elif defined(ESP32) +#include +#endif + +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +//------- ---------------------- ------ + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, SPOTIFY_REFRESH_TOKEN); + +void setup() +{ + + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + } + + delay(1000); + Serial.print("Going to start of track..."); + if (spotify.seek(0)) + { + Serial.println("done!"); + } + delay(2000); + Serial.print("Going to previous track..."); + if (spotify.previousTrack()) + { + Serial.println("done!"); + } + delay(2000); + Serial.print("Skipping to next track..."); + if (spotify.nextTrack()) + { + Serial.println("done!"); + } + + // Setting volume doesn't seem to work on my Android Phone + // It does work on my Desktop client + delay(2000); + Serial.print("set Volume 10%..."); + if (spotify.setVolume(10)) + { + Serial.println("done!"); + } + delay(2000); + Serial.print("set Volume 70%..."); + if (spotify.setVolume(70)) + { + Serial.println("done!"); + } + delay(2000); + Serial.print("Pausing..."); + if (spotify.pause()) + { + Serial.println("done!"); + } + delay(2000); + Serial.print("Playing..."); + if (spotify.play()) + { + Serial.println("done!"); + } + + delay(3000); + Serial.print("enabling shuffle..."); + if (spotify.toggleShuffle(true)) + { + Serial.println("done!"); + } + delay(3000); + Serial.print("disabling shuffle..."); + if (spotify.toggleShuffle(false)) + { + Serial.println("done!"); + } + + delay(3000); + Serial.print("Setting repeat mode to 'track'..."); + if (spotify.setRepeatMode(repeat_track)) + { + Serial.println("done!"); + } + delay(3000); + Serial.print("Setting repeat mode to 'context'..."); + if (spotify.setRepeatMode(repeat_context)) + { + Serial.println("done!"); + } + delay(3000); + Serial.print("Setting repeat mode to 'off'..."); + if (spotify.setRepeatMode(repeat_off)) + { + Serial.println("done!"); + } +} + +// Example code is at end of setup +void loop() +{ +} diff --git a/libraries/SpotifyArduino/examples/playerDetails/playerDetails.ino b/libraries/SpotifyArduino/examples/playerDetails/playerDetails.ino new file mode 100644 index 0000000..d26925b --- /dev/null +++ b/libraries/SpotifyArduino/examples/playerDetails/playerDetails.ino @@ -0,0 +1,232 @@ +/******************************************************************* + Prints info about your currently active spotify device + on the serial monitor using an ES32 or ESP8266 + + NOTE: You need to get a Refresh token to use this example + Use the getRefreshToken example to get it. + + Compatible Boards: + - Any ESP8266 or ESP32 board + + Parts: + ESP32 D1 Mini style Dev board* - http://s.click.aliexpress.com/e/C6ds4my + + * * = Affiliate + + If you find what I do useful and would like to support me, + please consider becoming a sponsor on Github + https://github.com/sponsors/witnessmenow/ + + + Written by Brian Lough + YouTube: https://www.youtube.com/brianlough + Tindie: https://www.tindie.com/stores/brianlough/ + Twitter: https://twitter.com/witnessmenow + *******************************************************************/ + +// ---------------------------- +// Standard Libraries +// ---------------------------- + +#if defined(ESP8266) +#include +#elif defined(ESP32) +#include +#endif + +#include + +// ---------------------------- +// Additional Libraries - each one of these will need to be installed. +// ---------------------------- + +#include +// Library for connecting to the Spotify API + +// Install from Github +// https://github.com/witnessmenow/spotify-api-arduino + +// including a "spotify_server_cert" variable +// header is included as part of the SpotifyArduino libary +#include + +#include +// Library used for parsing Json from the API responses + +// Search for "Arduino Json" in the Arduino Library manager +// https://github.com/bblanchon/ArduinoJson + +//------- Replace the following! ------ + +char ssid[] = "SSID"; // your network SSID (name) +char password[] = "password"; // your network password + +char clientId[] = "56t4373258u3405u43u543"; // Your client ID of your spotify APP +char clientSecret[] = "56t4373258u3405u43u543"; // Your client Secret of your spotify APP (Do Not share this!) + +// Country code, including this is advisable +#define SPOTIFY_MARKET "IE" + +#define SPOTIFY_REFRESH_TOKEN "AAAAAAAAAABBBBBBBBBBBCCCCCCCCCCCDDDDDDDDDDD" + +//------- ---------------------- ------ + +WiFiClientSecure client; +SpotifyArduino spotify(client, clientId, clientSecret, SPOTIFY_REFRESH_TOKEN); + +unsigned long delayBetweenRequests = 60000; // Time between requests (1 minute) +unsigned long requestDueTime; //time when request due + +void setup() +{ + + Serial.begin(115200); + + WiFi.mode(WIFI_STA); + WiFi.begin(ssid, password); + Serial.println(""); + + // Wait for connection + while (WiFi.status() != WL_CONNECTED) + { + delay(500); + Serial.print("."); + } + Serial.println(""); + Serial.print("Connected to "); + Serial.println(ssid); + Serial.print("IP address: "); + Serial.println(WiFi.localIP()); + + // Handle HTTPS Verification +#if defined(ESP8266) + client.setFingerprint(SPOTIFY_FINGERPRINT); // These expire every few months +#elif defined(ESP32) + client.setCACert(spotify_server_cert); +#endif + // ... or don't! + //client.setInsecure(); + + // If you want to enable some extra debugging + // uncomment the "#define SPOTIFY_DEBUG" in SpotifyArduino.h + + Serial.println("Refreshing Access Tokens"); + if (!spotify.refreshAccessToken()) + { + Serial.println("Failed to get access tokens"); + } +} + +void printPlayerDetailsToSerial(PlayerDetails playerDetails) +{ + Serial.println("--------- Player Details ---------"); + + Serial.print("Device ID: "); + Serial.println(playerDetails.device.id); + + Serial.print("Device Name: "); + Serial.println(playerDetails.device.name); + + Serial.print("Device Type: "); + Serial.println(playerDetails.device.type); + + Serial.print("Is Active: "); + if (playerDetails.device.isActive) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Is Resticted: "); + if (playerDetails.device.isRestricted) + { + Serial.println("Yes, from API docs \"no Web API commands will be accepted by this device\""); + } + else + { + Serial.println("No"); + } + + Serial.print("Is Private Session: "); + if (playerDetails.device.isPrivateSession) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Volume Percent: "); + Serial.println(playerDetails.device.volumePercent); + + Serial.print("Progress (Ms): "); + Serial.println(playerDetails.progressMs); + + Serial.print("Is Playing: "); + if (playerDetails.isPlaying) + { + Serial.println("Yes"); + } + else + { + Serial.println("No"); + } + + Serial.print("Shuffle State: "); + if (playerDetails.shuffleState) + { + Serial.println("On"); + } + else + { + Serial.println("Off"); + } + + Serial.print("Repeat State: "); + switch (playerDetails.repeateState) + { + case repeat_track: + Serial.println("track"); + break; + case repeat_context: + Serial.println("context"); + break; + case repeat_off: + Serial.println("off"); + break; + } + + Serial.println("------------------------"); +} + +void loop() +{ + if (millis() > requestDueTime) + { + Serial.print("Free Heap: "); + Serial.println(ESP.getFreeHeap()); + + Serial.println("Getting player Details:"); + // Market can be excluded if you want e.g. spotify.getPlayerDetails() + int status = spotify.getPlayerDetails(printPlayerDetailsToSerial, SPOTIFY_MARKET); + if (status == 200) + { + Serial.println("Successfully got player details"); + } + else if (status == 204) + { + Serial.println("No active player?"); + } + else + { + Serial.print("Error: "); + Serial.println(status); + } + + requestDueTime = millis() + delayBetweenRequests; + } +} diff --git a/libraries/SpotifyArduino/library.json b/libraries/SpotifyArduino/library.json new file mode 100644 index 0000000..68e5f36 --- /dev/null +++ b/libraries/SpotifyArduino/library.json @@ -0,0 +1,26 @@ +{ + "name":"SpotifyArduino", + "description":"A library to wrap the Spotify API (supports ESP8266/ESP32 & probably others)", + "keywords":"Spotify,Music,Arduino", + "authors": + { + "name": "Brian Lough ", + "maintainer": true + }, + "repository": + { + "type": "git", + "url": "https://github.com/witnessmenow/spotify-api-arduino" + }, + "version": "0.1.0", + "license": "MIT", + "frameworks": "arduino", + "platforms": "*", + "dependencies": [ + { + "name": "bblanchon/ArduinoJson" + } + ], + "build": { + } +} diff --git a/libraries/SpotifyArduino/library.properties b/libraries/SpotifyArduino/library.properties new file mode 100644 index 0000000..3e461f0 --- /dev/null +++ b/libraries/SpotifyArduino/library.properties @@ -0,0 +1,9 @@ +name=SpotifyArduino +version=0.1.0 +author=Brian Lough +maintainer=Brian Lough +sentence=A library to wrap the Spotify API (supports ESP8266/ESP32 & probably others) +paragraph=A library to wrap the Spotify API. This does not play music! It can be used to see what is currently playing and control the player. +category=Communication +url=https://github.com/witnessmenow/spotify-api-arduino +architectures=* \ No newline at end of file diff --git a/libraries/SpotifyArduino/platformio.ini b/libraries/SpotifyArduino/platformio.ini new file mode 100644 index 0000000..4c2ad7b --- /dev/null +++ b/libraries/SpotifyArduino/platformio.ini @@ -0,0 +1,49 @@ +; PlatformIO Project Configuration File + +; 64 ArduinoJson +; by Benoit Blanchon +; Repository: https://github.com/bblanchon/ArduinoJson.git +; 5538 WiFiNINA +; by Arduino +; Repository: https://github.com/arduino-libraries/WiFiNINA.git +; 3577 ESP32 64x32 LED MATRIX HUB75 DMA Display +; by Faptastic +; Repository: https://github.com/mrfaptastic/ESP32-RGB64x32MatrixPanel-I2S-DMA.git +; 13 Adafruit GFX Library +; by Adafruit +; Repository: https://github.com/adafruit/Adafruit-GFX-Library.git +; 6906 TJpg_Decoder +; by Bodmer +; Repository: https://github.com/Bodmer/TJpg_Decoder.git +; 6214 Adafruit BusIO +; by Adafruit +; Repository: https://github.com/adafruit/Adafruit_BusIO.git + +[platformio] +description = Arduino Spotify API Library +default_envs = d1_mini + +[env] +lib_deps = + ArduinoJson + 3577 + 13 + 6906 + 6214 + Wire +monitor_speed = 115200 + +[env:d1_mini] +platform = espressif8266 +board = d1_mini +framework = arduino + +[env:esp32dev] +platform = espressif32 +board = esp32dev +framework = arduino + +[env:nano_33_iot] +platform = atmelsam +board = nano_33_iot +framework = arduino \ No newline at end of file diff --git a/libraries/SpotifyArduino/scripts/travis/platformio.sh b/libraries/SpotifyArduino/scripts/travis/platformio.sh new file mode 100644 index 0000000..f778850 --- /dev/null +++ b/libraries/SpotifyArduino/scripts/travis/platformio.sh @@ -0,0 +1,3 @@ +#!/bin/sh -eux + +platformio ci $PWD/examples/$BOARDTYPE$EXAMPLE_FOLDER$EXAMPLE_NAME/ -b $BOARD --lib="." diff --git a/libraries/SpotifyArduino/scripts/travis/platformioSingle.sh b/libraries/SpotifyArduino/scripts/travis/platformioSingle.sh new file mode 100644 index 0000000..c7ad51c --- /dev/null +++ b/libraries/SpotifyArduino/scripts/travis/platformioSingle.sh @@ -0,0 +1,3 @@ +#!/bin/sh -eux + +platformio ci $PWD/examples/$EXAMPLE_FOLDER$EXAMPLE_NAME/$EXAMPLE_NAME.ino -l '.' -b $BOARD diff --git a/libraries/SpotifyArduino/src/SpotifyArduino.cpp b/libraries/SpotifyArduino/src/SpotifyArduino.cpp new file mode 100644 index 0000000..e3fe108 --- /dev/null +++ b/libraries/SpotifyArduino/src/SpotifyArduino.cpp @@ -0,0 +1,1321 @@ +/* +SpotifyArduino - An Arduino library to wrap the Spotify API + +Copyright (c) 2021 Brian Lough. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#include "SpotifyArduino.h" + +SpotifyArduino::SpotifyArduino(Client &client) +{ + this->client = &client; +} + +SpotifyArduino::SpotifyArduino(Client &client, char *bearerToken) +{ + this->client = &client; + sprintf(this->_bearerToken, "Bearer %s", bearerToken); +} + +SpotifyArduino::SpotifyArduino(Client &client, const char *clientId, const char *clientSecret, const char *refreshToken) +{ + this->client = &client; + this->_clientId = clientId; + this->_clientSecret = clientSecret; + setRefreshToken(refreshToken); +} + +int SpotifyArduino::makeRequestWithBody(const char *type, const char *command, const char *authorization, const char *body, const char *contentType, const char *host) +{ + client->flush(); +#ifdef SPOTIFY_DEBUG + Serial.println(host); +#endif + client->setTimeout(SPOTIFY_TIMEOUT); + if (!client->connect(host, portNumber)) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.println(F("Connection failed")); +#endif + return -1; + } + + // give the esp a breather + yield(); + + // Send HTTP request + client->print(type); + client->print(command); + client->println(F(" HTTP/1.0")); + + //Headers + client->print(F("Host: ")); + client->println(host); + + client->println(F("Accept: application/json")); + client->print(F("Content-Type: ")); + client->println(contentType); + + if (authorization != NULL) + { + client->print(F("Authorization: ")); + client->println(authorization); + } + + client->println(F("Cache-Control: no-cache")); + + client->print(F("Content-Length: ")); + client->println(strlen(body)); + + client->println(); + + client->print(body); + + if (client->println() == 0) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.println(F("Failed to send request")); +#endif + return -2; + } + + int statusCode = getHttpStatusCode(); + return statusCode; +} + +int SpotifyArduino::makePutRequest(const char *command, const char *authorization, const char *body, const char *contentType, const char *host) +{ + return makeRequestWithBody("PUT ", command, authorization, body, contentType); +} + +int SpotifyArduino::makePostRequest(const char *command, const char *authorization, const char *body, const char *contentType, const char *host) +{ + return makeRequestWithBody("POST ", command, authorization, body, contentType, host); +} + +int SpotifyArduino::makeGetRequest(const char *command, const char *authorization, const char *accept, const char *host) +{ + client->flush(); + client->setTimeout(SPOTIFY_TIMEOUT); + if (!client->connect(host, portNumber)) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.println(F("Connection failed")); +#endif + return -1; + } + + // give the esp a breather + yield(); + + // Send HTTP request + client->print(F("GET ")); + client->print(command); + client->println(F(" HTTP/1.0")); + + //Headers + client->print(F("Host: ")); + client->println(host); + + if (accept != NULL) + { + client->print(F("Accept: ")); + client->println(accept); + } + + if (authorization != NULL) + { + client->print(F("Authorization: ")); + client->println(authorization); + } + + client->println(F("Cache-Control: no-cache")); + + if (client->println() == 0) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.println(F("Failed to send request")); +#endif + return -2; + } + + int statusCode = getHttpStatusCode(); + + return statusCode; +} + +void SpotifyArduino::setRefreshToken(const char *refreshToken) +{ + int newRefreshTokenLen = strlen(refreshToken); + if (_refreshToken == NULL || strlen(_refreshToken) < newRefreshTokenLen) + { + delete _refreshToken; + _refreshToken = new char[newRefreshTokenLen + 1](); + } + + strncpy(_refreshToken, refreshToken, newRefreshTokenLen + 1); +} + +bool SpotifyArduino::refreshAccessToken() +{ + char body[300]; + sprintf(body, refreshAccessTokensBody, _refreshToken, _clientId, _clientSecret); + +#ifdef SPOTIFY_DEBUG + Serial.println(body); + printStack(); +#endif + + int statusCode = makePostRequest(SPOTIFY_TOKEN_ENDPOINT, NULL, body, "application/x-www-form-urlencoded", SPOTIFY_ACCOUNTS_HOST); + if (statusCode > 0) + { + skipHeaders(); + } + unsigned long now = millis(); + +#ifdef SPOTIFY_DEBUG + Serial.print("status Code"); + Serial.println(statusCode); +#endif + + bool refreshed = false; + if (statusCode == 200) + { + StaticJsonDocument<48> filter; + filter["access_token"] = true; + filter["token_type"] = true; + filter["expires_in"] = true; + + DynamicJsonDocument doc(512); + + // Parse JSON object +#ifndef SPOTIFY_PRINT_JSON_PARSE + DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter)); +#else + ReadLoggingStream loggingStream(*client, Serial); + DeserializationError error = deserializeJson(doc, loggingStream, DeserializationOption::Filter(filter)); +#endif + if (!error) + { +#ifdef SPOTIFY_DEBUG + Serial.println(F("No JSON error, dealing with response")); +#endif + const char *accessToken = doc["access_token"].as(); + if (accessToken != NULL && (SPOTIFY_ACCESS_TOKEN_LENGTH >= strlen(accessToken))) + { + sprintf(this->_bearerToken, "Bearer %s", accessToken); + int tokenTtl = doc["expires_in"]; // Usually 3600 (1 hour) + tokenTimeToLiveMs = (tokenTtl * 1000) - 2000; // The 2000 is just to force the token expiry to check if its very close + timeTokenRefreshed = now; + refreshed = true; + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("Problem with access_token (too long or null): ")); + Serial.println(accessToken); +#endif + } + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); +#endif + } + } + else + { + parseError(); + } + + closeClient(); + return refreshed; +} + +bool SpotifyArduino::checkAndRefreshAccessToken() +{ + unsigned long timeSinceLastRefresh = millis() - timeTokenRefreshed; + if (timeSinceLastRefresh >= tokenTimeToLiveMs) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.println("Refresh of the Access token is due, doing that now."); +#endif + return refreshAccessToken(); + } + + // Token is still valid + return true; +} + +const char *SpotifyArduino::requestAccessTokens(const char *code, const char *redirectUrl) +{ + + char body[500]; + sprintf(body, requestAccessTokensBody, code, redirectUrl, _clientId, _clientSecret); + +#ifdef SPOTIFY_DEBUG + Serial.println(body); +#endif + + int statusCode = makePostRequest(SPOTIFY_TOKEN_ENDPOINT, NULL, body, "application/x-www-form-urlencoded", SPOTIFY_ACCOUNTS_HOST); + if (statusCode > 0) + { + skipHeaders(); + } + unsigned long now = millis(); + +#ifdef SPOTIFY_DEBUG + Serial.print("status Code"); + Serial.println(statusCode); +#endif + + if (statusCode == 200) + { + DynamicJsonDocument doc(1000); + // Parse JSON object +#ifndef SPOTIFY_PRINT_JSON_PARSE + DeserializationError error = deserializeJson(doc, *client); +#else + ReadLoggingStream loggingStream(*client, Serial); + DeserializationError error = deserializeJson(doc, loggingStream); +#endif + if (!error) + { + sprintf(this->_bearerToken, "Bearer %s", doc["access_token"].as()); + setRefreshToken(doc["refresh_token"].as()); + int tokenTtl = doc["expires_in"]; // Usually 3600 (1 hour) + tokenTimeToLiveMs = (tokenTtl * 1000) - 2000; // The 2000 is just to force the token expiry to check if its very close + timeTokenRefreshed = now; + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); +#endif + } + } + else + { + parseError(); + } + + closeClient(); + return _refreshToken; +} + +bool SpotifyArduino::play(const char *deviceId) +{ + char command[100] = SPOTIFY_PLAY_ENDPOINT; + return playerControl(command, deviceId); +} + +bool SpotifyArduino::playAdvanced(char *body, const char *deviceId) +{ + char command[100] = SPOTIFY_PLAY_ENDPOINT; + return playerControl(command, deviceId, body); +} + +bool SpotifyArduino::pause(const char *deviceId) +{ + char command[100] = SPOTIFY_PAUSE_ENDPOINT; + return playerControl(command, deviceId); +} + +bool SpotifyArduino::setVolume(int volume, const char *deviceId) +{ + char command[125]; + sprintf(command, SPOTIFY_VOLUME_ENDPOINT, volume); + return playerControl(command, deviceId); +} + +bool SpotifyArduino::toggleShuffle(bool shuffle, const char *deviceId) +{ + char command[125]; + char shuffleState[10]; + if (shuffle) + { + strcpy(shuffleState, "true"); + } + else + { + strcpy(shuffleState, "false"); + } + sprintf(command, SPOTIFY_SHUFFLE_ENDPOINT, shuffleState); + return playerControl(command, deviceId); +} + +bool SpotifyArduino::setRepeatMode(RepeatOptions repeat, const char *deviceId) +{ + char command[125]; + char repeatState[10]; + switch (repeat) + { + case repeat_track: + strcpy(repeatState, "track"); + break; + case repeat_context: + strcpy(repeatState, "context"); + break; + case repeat_off: + strcpy(repeatState, "off"); + break; + } + + sprintf(command, SPOTIFY_REPEAT_ENDPOINT, repeatState); + return playerControl(command, deviceId); +} + +bool SpotifyArduino::playerControl(char *command, const char *deviceId, const char *body) +{ + if (deviceId[0] != 0) + { + char *questionMarkPointer; + questionMarkPointer = strchr(command, '?'); + char deviceIdBuff[50]; + if (questionMarkPointer == NULL) + { + sprintf(deviceIdBuff, "?device_id=%s", deviceId); + } + else + { + // params already started + sprintf(deviceIdBuff, "&device_id=%s", deviceId); + } + strcat(command, deviceIdBuff); + } + +#ifdef SPOTIFY_DEBUG + Serial.println(command); + Serial.println(body); +#endif + + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + int statusCode = makePutRequest(command, _bearerToken, body); + + closeClient(); + //Will return 204 if all went well. + return statusCode == 204; +} + +bool SpotifyArduino::playerNavigate(char *command, const char *deviceId) +{ + if (deviceId[0] != 0) + { + char deviceIdBuff[50]; + sprintf(deviceIdBuff, "?device_id=%s", deviceId); + strcat(command, deviceIdBuff); + } + +#ifdef SPOTIFY_DEBUG + Serial.println(command); +#endif + + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + int statusCode = makePostRequest(command, _bearerToken); + + closeClient(); + //Will return 204 if all went well. + return statusCode == 204; +} + +bool SpotifyArduino::nextTrack(const char *deviceId) +{ + char command[100] = SPOTIFY_NEXT_TRACK_ENDPOINT; + return playerNavigate(command, deviceId); +} + +bool SpotifyArduino::previousTrack(const char *deviceId) +{ + char command[100] = SPOTIFY_PREVIOUS_TRACK_ENDPOINT; + return playerNavigate(command, deviceId); +} +bool SpotifyArduino::seek(int position, const char *deviceId) +{ + char command[100] = SPOTIFY_SEEK_ENDPOINT; + char tempBuff[100]; + sprintf(tempBuff, "?position_ms=%d", position); + strcat(command, tempBuff); + if (deviceId[0] != 0) + { + sprintf(tempBuff, "?device_id=%s", deviceId); + strcat(command, tempBuff); + } + +#ifdef SPOTIFY_DEBUG + Serial.println(command); + printStack(); +#endif + + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + int statusCode = makePutRequest(command, _bearerToken); + closeClient(); + //Will return 204 if all went well. + return statusCode == 204; +} + +bool SpotifyArduino::transferPlayback(const char *deviceId, bool play) +{ + char body[100]; + sprintf(body, "{\"device_ids\":[\"%s\"],\"play\":\"%s\"}", deviceId, (play ? "true" : "false")); + +#ifdef SPOTIFY_DEBUG + Serial.println(SPOTIFY_PLAYER_ENDPOINT); + Serial.println(body); + printStack(); +#endif + + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + int statusCode = makePutRequest(SPOTIFY_PLAYER_ENDPOINT, _bearerToken, body); + closeClient(); + //Will return 204 if all went well. + return statusCode == 204; +} + +int SpotifyArduino::getCurrentlyPlaying(processCurrentlyPlaying currentlyPlayingCallback, const char *market) +{ + char command[75] = SPOTIFY_CURRENTLY_PLAYING_ENDPOINT; + if (market[0] != 0) + { + char marketBuff[15]; + sprintf(marketBuff, "&market=%s", market); + strcat(command, marketBuff); + } + +#ifdef SPOTIFY_DEBUG + Serial.println(command); + printStack(); +#endif + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = currentlyPlayingBufferSize; + + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + int statusCode = makeGetRequest(command, _bearerToken); +#ifdef SPOTIFY_DEBUG + Serial.print("Status Code: "); + Serial.println(statusCode); + printStack(); +#endif + if (statusCode > 0) + { + skipHeaders(); + } + + if (statusCode == 200) + { + CurrentlyPlaying current; + + //Apply Json Filter: https://arduinojson.org/v6/example/filter/ + StaticJsonDocument<464> filter; + filter["is_playing"] = true; + filter["currently_playing_type"] = true; + filter["progress_ms"] = true; + filter["context"]["uri"] = true; + + JsonObject filter_item = filter.createNestedObject("item"); + filter_item["duration_ms"] = true; + filter_item["name"] = true; + filter_item["uri"] = true; + + JsonObject filter_item_artists_0 = filter_item["artists"].createNestedObject(); + filter_item_artists_0["name"] = true; + filter_item_artists_0["uri"] = true; + + JsonObject filter_item_album = filter_item.createNestedObject("album"); + filter_item_album["name"] = true; + filter_item_album["uri"] = true; + + JsonObject filter_item_album_images_0 = filter_item_album["images"].createNestedObject(); + filter_item_album_images_0["height"] = true; + filter_item_album_images_0["width"] = true; + filter_item_album_images_0["url"] = true; + + // Podcast filters + JsonObject filter_item_show = filter_item.createNestedObject("show"); + filter_item_show["name"] = true; + filter_item_show["uri"] = true; + + JsonObject filter_item_images_0 = filter_item["images"].createNestedObject(); + filter_item_images_0["height"] = true; + filter_item_images_0["width"] = true; + filter_item_images_0["url"] = true; + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object +#ifndef SPOTIFY_PRINT_JSON_PARSE + DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter)); +#else + ReadLoggingStream loggingStream(*client, Serial); + DeserializationError error = deserializeJson(doc, loggingStream, DeserializationOption::Filter(filter)); +#endif + if (!error) + { +#ifdef SPOTIFY_DEBUG + serializeJsonPretty(doc, Serial); +#endif + JsonObject item = doc["item"]; + + const char *currently_playing_type = doc["currently_playing_type"]; + + current.isPlaying = doc["is_playing"].as(); + + current.progressMs = doc["progress_ms"].as(); + current.durationMs = item["duration_ms"].as(); + + // context may be null + if (!doc["context"].isNull()) + { + current.contextUri = doc["context"]["uri"].as(); + } + else + { + current.contextUri = NULL; + } + + // Check currently playing type + if (strcmp(currently_playing_type, "track") == 0) + { + current.currentlyPlayingType = track; + } + else if (strcmp(currently_playing_type, "episode") == 0) + { + current.currentlyPlayingType = episode; + } + else + { + current.currentlyPlayingType = other; + } + + // If it's a song/track + if (current.currentlyPlayingType == track) + { + int numArtists = item["artists"].size(); + if (numArtists > SPOTIFY_MAX_NUM_ARTISTS) + { + numArtists = SPOTIFY_MAX_NUM_ARTISTS; + } + current.numArtists = numArtists; + + for (int i = 0; i < current.numArtists; i++) + { + current.artists[i].artistName = item["artists"][i]["name"].as(); + current.artists[i].artistUri = item["artists"][i]["uri"].as(); + } + + current.albumName = item["album"]["name"].as(); + current.albumUri = item["album"]["uri"].as(); + + JsonArray images = item["album"]["images"]; + + // Images are returned in order of width, so last should be smallest. + int numImages = images.size(); + int startingIndex = 0; + if (numImages > SPOTIFY_NUM_ALBUM_IMAGES) + { + startingIndex = numImages - SPOTIFY_NUM_ALBUM_IMAGES; + current.numImages = SPOTIFY_NUM_ALBUM_IMAGES; + } + else + { + current.numImages = numImages; + } +#ifdef SPOTIFY_DEBUG + Serial.print(F("Num Images: ")); + Serial.println(current.numImages); + Serial.println(numImages); +#endif + + for (int i = 0; i < current.numImages; i++) + { + int adjustedIndex = startingIndex + i; + current.albumImages[i].height = images[adjustedIndex]["height"].as(); + current.albumImages[i].width = images[adjustedIndex]["width"].as(); + current.albumImages[i].url = images[adjustedIndex]["url"].as(); + } + + current.trackName = item["name"].as(); + current.trackUri = item["uri"].as(); + } + else if (current.currentlyPlayingType == episode) // Podcast + { + current.numArtists = 1; + + // Save Podcast as the "track" + current.trackName = item["name"].as(); + current.trackUri = item["uri"].as(); + + // Save Show name as the "artist" + current.artists[0].artistName = item["show"]["name"].as(); + current.artists[0].artistUri = item["show"]["uri"].as(); + + // Leave "album" name blank + char blank[1] = ""; + current.albumName = blank; + current.albumUri = blank; + + // Save the episode images as the "album art" + JsonArray images = item["images"]; + // Images are returned in order of width, so last should be smallest. + int numImages = images.size(); + int startingIndex = 0; + if (numImages > SPOTIFY_NUM_ALBUM_IMAGES) + { + startingIndex = numImages - SPOTIFY_NUM_ALBUM_IMAGES; + current.numImages = SPOTIFY_NUM_ALBUM_IMAGES; + } + else + { + current.numImages = numImages; + } +#ifdef SPOTIFY_DEBUG + Serial.print(F("Num Images: ")); + Serial.println(current.numImages); + Serial.println(numImages); +#endif + + for (int i = 0; i < current.numImages; i++) + { + int adjustedIndex = startingIndex + i; + current.albumImages[i].height = images[adjustedIndex]["height"].as(); + current.albumImages[i].width = images[adjustedIndex]["width"].as(); + current.albumImages[i].url = images[adjustedIndex]["url"].as(); + } + } + + currentlyPlayingCallback(current); + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); +#endif + statusCode = -1; + } + } + + closeClient(); + return statusCode; +} + +int SpotifyArduino::getPlayerDetails(processPlayerDetails playerDetailsCallback, const char *market) +{ + char command[100] = SPOTIFY_PLAYER_ENDPOINT; + if (market[0] != 0) + { + char marketBuff[30]; + sprintf(marketBuff, "?market=%s", market); + strcat(command, marketBuff); + } + +#ifdef SPOTIFY_DEBUG + Serial.println(command); + printStack(); +#endif + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = playerDetailsBufferSize; + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + + int statusCode = makeGetRequest(command, _bearerToken); +#ifdef SPOTIFY_DEBUG + Serial.print("Status Code: "); + Serial.println(statusCode); +#endif + if (statusCode > 0) + { + skipHeaders(); + } + + if (statusCode == 200) + { + + StaticJsonDocument<192> filter; + JsonObject filter_device = filter.createNestedObject("device"); + filter_device["id"] = true; + filter_device["name"] = true; + filter_device["type"] = true; + filter_device["is_active"] = true; + filter_device["is_private_session"] = true; + filter_device["is_restricted"] = true; + filter_device["volume_percent"] = true; + filter["progress_ms"] = true; + filter["is_playing"] = true; + filter["shuffle_state"] = true; + filter["repeat_state"] = true; + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object +#ifndef SPOTIFY_PRINT_JSON_PARSE + DeserializationError error = deserializeJson(doc, *client, DeserializationOption::Filter(filter)); +#else + ReadLoggingStream loggingStream(*client, Serial); + DeserializationError error = deserializeJson(doc, loggingStream, DeserializationOption::Filter(filter)); +#endif + if (!error) + { + PlayerDetails playerDetails; + + JsonObject device = doc["device"]; + // Copy into buffer and make the last character a null just incase we went over. + playerDetails.device.id = device["id"].as(); + playerDetails.device.name = device["name"].as(); + playerDetails.device.type = device["type"].as(); + + playerDetails.device.isActive = device["is_active"].as(); + playerDetails.device.isPrivateSession = device["is_private_session"].as(); + playerDetails.device.isRestricted = device["is_restricted"].as(); + playerDetails.device.volumePercent = device["volume_percent"].as(); + + playerDetails.progressMs = doc["progress_ms"].as(); + playerDetails.isPlaying = doc["is_playing"].as(); + + playerDetails.shuffleState = doc["shuffle_state"].as(); + + const char *repeat_state = doc["repeat_state"]; + + if (strncmp(repeat_state, "track", 5) == 0) + { + playerDetails.repeateState = repeat_track; + } + else if (strncmp(repeat_state, "context", 7) == 0) + { + playerDetails.repeateState = repeat_context; + } + else + { + playerDetails.repeateState = repeat_off; + } + + playerDetailsCallback(playerDetails); + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); +#endif + statusCode = -1; + } + } + + closeClient(); + return statusCode; +} + +int SpotifyArduino::getDevices(processDevices devicesCallback) +{ + +#ifdef SPOTIFY_DEBUG + Serial.println(SPOTIFY_DEVICES_ENDPOINT); + printStack(); +#endif + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = getDevicesBufferSize; + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + + int statusCode = makeGetRequest(SPOTIFY_DEVICES_ENDPOINT, _bearerToken); +#ifdef SPOTIFY_DEBUG + Serial.print("Status Code: "); + Serial.println(statusCode); +#endif + if (statusCode > 0) + { + skipHeaders(); + } + + if (statusCode == 200) + { + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object +#ifndef SPOTIFY_PRINT_JSON_PARSE + DeserializationError error = deserializeJson(doc, *client); +#else + ReadLoggingStream loggingStream(*client, Serial); + DeserializationError error = deserializeJson(doc, loggingStream); +#endif + if (!error) + { + + uint8_t totalDevices = doc["devices"].size(); + + SpotifyDevice spotifyDevice; + for (int i = 0; i < totalDevices; i++) + { + JsonObject device = doc["devices"][i]; + spotifyDevice.id = device["id"].as(); + spotifyDevice.name = device["name"].as(); + spotifyDevice.type = device["type"].as(); + + spotifyDevice.isActive = device["is_active"].as(); + spotifyDevice.isPrivateSession = device["is_private_session"].as(); + spotifyDevice.isRestricted = device["is_restricted"].as(); + spotifyDevice.volumePercent = device["volume_percent"].as(); + + if (!devicesCallback(spotifyDevice, i, totalDevices)) + { + //User has indicated they are finished. + break; + } + } + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); +#endif + statusCode = -1; + } + } + + closeClient(); + return statusCode; +} + +int SpotifyArduino::searchForSong(String query, int limit, processSearch searchCallback, SearchResult results[]) +{ + +#ifdef SPOTIFY_DEBUG + Serial.println(SPOTIFY_SEARCH_ENDPOINT); + printStack(); +#endif + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = searchDetailsBufferSize; + if (autoTokenRefresh) + { + checkAndRefreshAccessToken(); + } + + int statusCode = makeGetRequest((SPOTIFY_SEARCH_ENDPOINT + query + "&limit=" + limit).c_str(), _bearerToken); +#ifdef SPOTIFY_DEBUG + Serial.print("Status Code: "); + Serial.println(statusCode); +#endif + if (statusCode > 0) + { + skipHeaders(); + } + + if (statusCode == 200) + { + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object +#ifndef SPOTIFY_PRINT_JSON_PARSE + DeserializationError error = deserializeJson(doc, *client); +#else + ReadLoggingStream loggingStream(*client, Serial); + DeserializationError error = deserializeJson(doc, loggingStream); +#endif + if (!error) + { + + uint8_t totalResults = doc["tracks"]["items"].size(); + + Serial.print("Total Results: "); + Serial.println(totalResults); + + SearchResult searchResult; + for (int i = 0; i < totalResults; i++) + { + //Polling track information + JsonObject result = doc["tracks"]["items"][i]; + searchResult.trackUri = result["uri"].as(); + searchResult.trackName = result["name"].as(); + searchResult.albumUri = result["album"]["uri"].as(); + searchResult.albumName = result["album"]["name"].as(); + + //Pull artist Information for the result + uint8_t totalArtists = result["artists"].size(); + searchResult.numArtists = totalArtists; + + SpotifyArtist artist; + for (int j = 0; j < totalArtists; j++) + { + JsonObject artistResult = result["artists"][j]; + artist.artistName = artistResult["name"].as(); + artist.artistUri = artistResult["uri"].as(); + searchResult.artists[j] = artist; + } + + uint8_t totalImages = result["album"]["images"].size(); + searchResult.numImages = totalImages; + + SpotifyImage image; + for (int j = 0; j < totalImages; j++) + { + JsonObject imageResult = result["album"]["images"][j]; + image.height = imageResult["height"].as(); + image.width = imageResult["width"].as(); + image.url = imageResult["url"].as(); + searchResult.albumImages[j] = image; + } + + //Serial.println(searchResult.trackName); + results[i] = searchResult; + + if (i >= limit || !searchCallback(searchResult, i, totalResults)) + { + //Break at the limit or when indicated + break; + } + } + } + else + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); +#endif + statusCode = -1; + } + } + + closeClient(); + return statusCode; +} + +int SpotifyArduino::commonGetImage(char *imageUrl) +{ +#ifdef SPOTIFY_DEBUG + Serial.print(F("Parsing image URL: ")); + Serial.println(imageUrl); +#endif + + uint8_t lengthOfString = strlen(imageUrl); + + // We are going to just assume https, that's all I've + // seen and I can't imagine a company will go back + // to http + + if (strncmp(imageUrl, "https://", 8) != 0) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.print(F("Url not in expected format: ")); + Serial.println(imageUrl); + Serial.println("(expected it to start with \"https://\")"); +#endif + return false; + } + + uint8_t protocolLength = 8; + + char *pathStart = strchr(imageUrl + protocolLength, '/'); + uint8_t pathIndex = pathStart - imageUrl; + uint8_t pathLength = lengthOfString - pathIndex; + char path[pathLength + 1]; + strncpy(path, pathStart, pathLength); + path[pathLength] = '\0'; + + uint8_t hostLength = pathIndex - protocolLength; + char host[hostLength + 1]; + strncpy(host, imageUrl + protocolLength, hostLength); + host[hostLength] = '\0'; + +#ifdef SPOTIFY_DEBUG + + Serial.print(F("host: ")); + Serial.println(host); + + Serial.print(F("len:host:")); + Serial.println(hostLength); + + Serial.print(F("path: ")); + Serial.println(path); + + Serial.print(F("len:path: ")); + Serial.println(strlen(path)); +#endif + + int statusCode = makeGetRequest(path, NULL, "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", host); +#ifdef SPOTIFY_DEBUG + Serial.print(F("statusCode: ")); + Serial.println(statusCode); +#endif + if (statusCode == 200) + { + return getContentLength(); + } + + // Failed + return -1; +} + +bool SpotifyArduino::getImage(char *imageUrl, Stream *file) +{ + int totalLength = commonGetImage(imageUrl); + +#ifdef SPOTIFY_DEBUG + Serial.print(F("file length: ")); + Serial.println(totalLength); +#endif + if (totalLength > 0) + { + skipHeaders(false); + int remaining = totalLength; + // This section of code is inspired but the "Web_Jpg" + // example of TJpg_Decoder + // https://github.com/Bodmer/TJpg_Decoder + // ----------- + uint8_t buff[128] = {0}; + while (client->connected() && (remaining > 0 || remaining == -1)) + { + // Get available data size + size_t size = client->available(); + + if (size) + { + // Read up to 128 bytes + int c = client->readBytes(buff, ((size > sizeof(buff)) ? sizeof(buff) : size)); + + // Write it to file + file->write(buff, c); + + // Calculate remaining bytes + if (remaining > 0) + { + remaining -= c; + } + } + yield(); + } +// --------- +#ifdef SPOTIFY_DEBUG + Serial.println(F("Finished getting image")); +#endif + } + + closeClient(); + + return (totalLength > 0); //Probably could be improved! +} + +bool SpotifyArduino::getImage(char *imageUrl, uint8_t **image, int *imageLength) +{ + int totalLength = commonGetImage(imageUrl); + +#ifdef SPOTIFY_DEBUG + Serial.print(F("file length: ")); + Serial.println(totalLength); +#endif + if (totalLength > 0) + { + skipHeaders(false); + uint8_t *imgPtr = (uint8_t *)malloc(totalLength); + *image = imgPtr; + *imageLength = totalLength; + int remaining = totalLength; + int amountRead = 0; + +#ifdef SPOTIFY_DEBUG + Serial.println(F("Fetching Image")); +#endif + + // This section of code is inspired but the "Web_Jpg" + // example of TJpg_Decoder + // https://github.com/Bodmer/TJpg_Decoder + // ----------- + uint8_t buff[128] = {0}; + while (client->connected() && (remaining > 0 || remaining == -1)) + { + // Get available data size + size_t size = client->available(); + + if (size) + { + // Read up to 128 bytes + int c = client->readBytes(buff, ((size > sizeof(buff)) ? sizeof(buff) : size)); + + // Write it to file + memcpy((uint8_t *)imgPtr + amountRead, (uint8_t *)buff, c); + + // Calculate remaining bytes + if (remaining > 0) + { + amountRead += c; + remaining -= c; + } + } + yield(); + } +// --------- +#ifdef SPOTIFY_DEBUG + Serial.println(F("Finished getting image")); +#endif + } + + closeClient(); + + return (totalLength > 0); //Probably could be improved! +} + +int SpotifyArduino::getContentLength() +{ + + if (client->find("Content-Length:")) + { + int contentLength = client->parseInt(); +#ifdef SPOTIFY_DEBUG + Serial.print(F("Content-Length: ")); + Serial.println(contentLength); +#endif + return contentLength; + } + + return -1; +} + +void SpotifyArduino::skipHeaders(bool tossUnexpectedForJSON) +{ + // Skip HTTP headers + if (!client->find("\r\n\r\n")) + { +#ifdef SPOTIFY_SERIAL_OUTPUT + Serial.println(F("Invalid response")); +#endif + return; + } + + if (tossUnexpectedForJSON) + { + // Was getting stray characters between the headers and the body + // This should toss them away + while (client->available() && client->peek() != '{') + { + char c = 0; + client->readBytes(&c, 1); +#ifdef SPOTIFY_DEBUG + Serial.print(F("Tossing an unexpected character: ")); + Serial.println(c); +#endif + } + } +} + +int SpotifyArduino::getHttpStatusCode() +{ + char status[32] = {0}; + client->readBytesUntil('\r', status, sizeof(status)); +#ifdef SPOTIFY_DEBUG + Serial.print(F("Status: ")); + Serial.println(status); +#endif + + char *token; + token = strtok(status, " "); // https://www.tutorialspoint.com/c_standard_library/c_function_strtok.htm + +#ifdef SPOTIFY_DEBUG + Serial.print(F("HTTP Version: ")); + Serial.println(token); +#endif + + if (token != NULL && (strcmp(token, "HTTP/1.0") == 0 || strcmp(token, "HTTP/1.1") == 0)) + { + token = strtok(NULL, " "); + if (token != NULL) + { +#ifdef SPOTIFY_DEBUG + Serial.print(F("Status Code: ")); + Serial.println(token); +#endif + return atoi(token); + } + } + + return -1; +} + +void SpotifyArduino::parseError() +{ + //This method doesn't currently do anything other than print +#ifdef SPOTIFY_SERIAL_OUTPUT + DynamicJsonDocument doc(1000); + DeserializationError error = deserializeJson(doc, *client); + if (!error) + { + Serial.print(F("getAuthToken error")); + serializeJson(doc, Serial); + } + else + { + Serial.print(F("Could not parse error")); + } +#endif +} + +void SpotifyArduino::lateInit(const char *clientId, const char *clientSecret, const char *refreshToken) +{ + this->_clientId = clientId; + this->_clientSecret = clientSecret; + setRefreshToken(refreshToken); +} + +void SpotifyArduino::closeClient() +{ + if (client->connected()) + { +#ifdef SPOTIFY_DEBUG + Serial.println(F("Closing client")); +#endif + client->stop(); + } +} + +#ifdef SPOTIFY_DEBUG +void SpotifyArduino::printStack() +{ + char stack; + Serial.print(F("stack size ")); + Serial.println(stack_start - &stack); +} +#endif diff --git a/libraries/SpotifyArduino/src/SpotifyArduino.h b/libraries/SpotifyArduino/src/SpotifyArduino.h new file mode 100644 index 0000000..102ca46 --- /dev/null +++ b/libraries/SpotifyArduino/src/SpotifyArduino.h @@ -0,0 +1,249 @@ +/* +SpotifyArduino - An Arduino library to wrap the Spotify API + +Copyright (c) 2020 Brian Lough. + +This library is free software; you can redistribute it and/or +modify it under the terms of the GNU Lesser General Public +License as published by the Free Software Foundation; either +version 2.1 of the License, or (at your option) any later version. + +This library is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public +License along with this library; if not, write to the Free Software +Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +*/ + +#ifndef SpotifyArduino_h +#define SpotifyArduino_h + +// I find setting these types of flags unreliable from the Arduino IDE +// so uncomment this if its not working for you. +// NOTE: Do not use this option on live-streams, it will reveal your +// private tokens! + +#define SPOTIFY_DEBUG 1 + +// Comment out if you want to disable any serial output from this library (also comment out DEBUG and PRINT_JSON_PARSE) +#define SPOTIFY_SERIAL_OUTPUT 1 + +// Prints the JSON received to serial (only use for debugging as it will be slow) +//#define SPOTIFY_PRINT_JSON_PARSE 1 + +#include +#include +#include + +#ifdef SPOTIFY_PRINT_JSON_PARSE +#include +#endif + +#define SPOTIFY_HOST "api.spotify.com" +#define SPOTIFY_ACCOUNTS_HOST "accounts.spotify.com" + +// Fingerprint for "*.spotify.com", correct as of March 14, 2024 +#define SPOTIFY_FINGERPRINT "69 2B 36 29 F0 B5 FC 1B A3 57 A6 76 E6 92 EF 30 14 22 34 6A" + +// Fingerprint for "*.scdn.co", correct as of March 14, 2024 +#define SPOTIFY_IMAGE_SERVER_FINGERPRINT "0A 0F 59 45 2C FF 37 3C FE 37 27 AD 32 64 59 A9 5A B6 2F 30" + +#define SPOTIFY_TIMEOUT 2000 + +#define SPOTIFY_NAME_CHAR_LENGTH 100 //Increase if artists/song/album names are being cut off +#define SPOTIFY_URI_CHAR_LENGTH 40 +#define SPOTIFY_URL_CHAR_LENGTH 70 + +#define SPOTIFY_DEVICE_ID_CHAR_LENGTH 45 +#define SPOTIFY_DEVICE_NAME_CHAR_LENGTH 80 +#define SPOTIFY_DEVICE_TYPE_CHAR_LENGTH 30 + +#define SPOTIFY_CURRENTLY_PLAYING_ENDPOINT "/v1/me/player/currently-playing?additional_types=episode" + +#define SPOTIFY_PLAYER_ENDPOINT "/v1/me/player" +#define SPOTIFY_DEVICES_ENDPOINT "/v1/me/player/devices" + +#define SPOTIFY_PLAY_ENDPOINT "/v1/me/player/play" +#define SPOTIFY_SEARCH_ENDPOINT "/v1/search" +#define SPOTIFY_PAUSE_ENDPOINT "/v1/me/player/pause" +#define SPOTIFY_VOLUME_ENDPOINT "/v1/me/player/volume?volume_percent=%d" +#define SPOTIFY_SHUFFLE_ENDPOINT "/v1/me/player/shuffle?state=%s" +#define SPOTIFY_REPEAT_ENDPOINT "/v1/me/player/repeat?state=%s" + +#define SPOTIFY_NEXT_TRACK_ENDPOINT "/v1/me/player/next" +#define SPOTIFY_PREVIOUS_TRACK_ENDPOINT "/v1/me/player/previous" + +#define SPOTIFY_SEEK_ENDPOINT "/v1/me/player/seek" + +#define SPOTIFY_TOKEN_ENDPOINT "/api/token" + +#define SPOTIFY_NUM_ALBUM_IMAGES 3 // Max spotify returns is 3, but the third one is probably too big for an ESP + +#define SPOTIFY_MAX_NUM_ARTISTS 5 + +#define SPOTIFY_ACCESS_TOKEN_LENGTH 309 + +enum RepeatOptions +{ + repeat_track, + repeat_context, + repeat_off +}; + +enum SpotifyPlayingType +{ + track, + episode, + other +}; + +struct SpotifyImage +{ + int height; + int width; + const char *url; +}; + +struct SpotifyDevice +{ + const char *id; + const char *name; + const char *type; + bool isActive; + bool isRestricted; + bool isPrivateSession; + int volumePercent; +}; + +struct PlayerDetails +{ + SpotifyDevice device; + + long progressMs; + bool isPlaying; + RepeatOptions repeateState; + bool shuffleState; +}; + +struct SpotifyArtist +{ + const char *artistName; + const char *artistUri; +}; + +struct SearchResult +{ + const char *albumName; + const char *albumUri; + const char *trackName; + const char *trackUri; + SpotifyArtist artists[SPOTIFY_MAX_NUM_ARTISTS]; + SpotifyImage albumImages[SPOTIFY_NUM_ALBUM_IMAGES]; + int numArtists; + int numImages; +}; + +struct CurrentlyPlaying +{ + SpotifyArtist artists[SPOTIFY_MAX_NUM_ARTISTS]; + int numArtists; + const char *albumName; + const char *albumUri; + const char *trackName; + const char *trackUri; + SpotifyImage albumImages[SPOTIFY_NUM_ALBUM_IMAGES]; + int numImages; + bool isPlaying; + long progressMs; + long durationMs; + const char *contextUri; + SpotifyPlayingType currentlyPlayingType; +}; + +typedef void (*processCurrentlyPlaying)(CurrentlyPlaying currentlyPlaying); +typedef void (*processPlayerDetails)(PlayerDetails playerDetails); +typedef bool (*processDevices)(SpotifyDevice device, int index, int numDevices); +typedef bool (*processSearch)(SearchResult result, int index, int numResults); + +class SpotifyArduino +{ +public: + SpotifyArduino(Client &client); + SpotifyArduino(Client &client, char *bearerToken); + SpotifyArduino(Client &client, const char *clientId, const char *clientSecret, const char *refreshToken = ""); + + // Auth Methods + void setRefreshToken(const char *refreshToken); + bool refreshAccessToken(); + bool checkAndRefreshAccessToken(); + const char *requestAccessTokens(const char *code, const char *redirectUrl); + + // Generic Request Methods + int makeGetRequest(const char *command, const char *authorization, const char *accept = "application/json", const char *host = SPOTIFY_HOST); + int makeRequestWithBody(const char *type, const char *command, const char *authorization, const char *body = "", const char *contentType = "application/json", const char *host = SPOTIFY_HOST); + int makePostRequest(const char *command, const char *authorization, const char *body = "", const char *contentType = "application/json", const char *host = SPOTIFY_HOST); + int makePutRequest(const char *command, const char *authorization, const char *body = "", const char *contentType = "application/json", const char *host = SPOTIFY_HOST); + + // User methods + int getCurrentlyPlaying(processCurrentlyPlaying currentlyPlayingCallback, const char *market = ""); + int getPlayerDetails(processPlayerDetails playerDetailsCallback, const char *market = ""); + int getDevices(processDevices devicesCallback); + bool play(const char *deviceId = ""); + bool playAdvanced(char *body, const char *deviceId = ""); + bool pause(const char *deviceId = ""); + bool setVolume(int volume, const char *deviceId = ""); + bool toggleShuffle(bool shuffle, const char *deviceId = ""); + bool setRepeatMode(RepeatOptions repeat, const char *deviceId = ""); + bool nextTrack(const char *deviceId = ""); + bool previousTrack(const char *deviceId = ""); + bool playerControl(char *command, const char *deviceId = "", const char *body = ""); + bool playerNavigate(char *command, const char *deviceId = ""); + bool seek(int position, const char *deviceId = ""); + bool transferPlayback(const char *deviceId, bool play = false); + + //Search + int searchForSong(String query, int limit, processSearch searchCallback, SearchResult results[]); + + // Image methods + bool getImage(char *imageUrl, Stream *file); + bool getImage(char *imageUrl, uint8_t **image, int *imageLength); + + int portNumber = 443; + int currentlyPlayingBufferSize = 3000; + int playerDetailsBufferSize = 2000; + int getDevicesBufferSize = 3000; + int searchDetailsBufferSize = 3000; + bool autoTokenRefresh = true; + Client *client; + void lateInit(const char *clientId, const char *clientSecret, const char *refreshToken = ""); + +#ifdef SPOTIFY_DEBUG + char *stack_start; +#endif + +private: + char _bearerToken[SPOTIFY_ACCESS_TOKEN_LENGTH + 10]; //10 extra is for "bearer " at the start + char *_refreshToken; + const char *_clientId; + const char *_clientSecret; + unsigned int timeTokenRefreshed; + unsigned int tokenTimeToLiveMs; + int commonGetImage(char *imageUrl); + int getContentLength(); + int getHttpStatusCode(); + void skipHeaders(bool tossUnexpectedForJSON = true); + void closeClient(); + void parseError(); + const char *requestAccessTokensBody = + R"(grant_type=authorization_code&code=%s&redirect_uri=%s&client_id=%s&client_secret=%s)"; + const char *refreshAccessTokensBody = + R"(grant_type=refresh_token&refresh_token=%s&client_id=%s&client_secret=%s)"; +#ifdef SPOTIFY_DEBUG + void printStack(); +#endif +}; + +#endif diff --git a/libraries/SpotifyArduino/src/SpotifyArduinoCert.h b/libraries/SpotifyArduino/src/SpotifyArduinoCert.h new file mode 100644 index 0000000..943f4d4 --- /dev/null +++ b/libraries/SpotifyArduino/src/SpotifyArduinoCert.h @@ -0,0 +1,47 @@ +// DigiCert Global Root CA Cert - as of 01/10/2023 +const char *spotify_server_cert = "-----BEGIN CERTIFICATE-----\n" + "MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n" + "MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n" + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n" + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n" + "2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n" + "1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n" + "q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n" + "tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n" + "vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n" + "BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n" + "5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n" + "1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n" + "NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n" + "Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n" + "8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n" + "pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n" + "MrY=\n" + "-----END CERTIFICATE-----\n"; + +// DigiCert Global Root G2 Cert - as of 01/10/2023 +const char *spotify_image_server_cert = "-----BEGIN CERTIFICATE-----\n" + "MIIDjjCCAnagAwIBAgIQAzrx5qcRqaC7KGSxHQn65TANBgkqhkiG9w0BAQsFADBh\n" + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n" + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n" + "MjAeFw0xMzA4MDExMjAwMDBaFw0zODAxMTUxMjAwMDBaMGExCzAJBgNVBAYTAlVT\n" + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5j\n" + "b20xIDAeBgNVBAMTF0RpZ2lDZXJ0IEdsb2JhbCBSb290IEcyMIIBIjANBgkqhkiG\n" + "9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuzfNNNx7a8myaJCtSnX/RrohCgiN9RlUyfuI\n" + "2/Ou8jqJkTx65qsGGmvPrC3oXgkkRLpimn7Wo6h+4FR1IAWsULecYxpsMNzaHxmx\n" + "1x7e/dfgy5SDN67sH0NO3Xss0r0upS/kqbitOtSZpLYl6ZtrAGCSYP9PIUkY92eQ\n" + "q2EGnI/yuum06ZIya7XzV+hdG82MHauVBJVJ8zUtluNJbd134/tJS7SsVQepj5Wz\n" + "tCO7TG1F8PapspUwtP1MVYwnSlcUfIKdzXOS0xZKBgyMUNGPHgm+F6HmIcr9g+UQ\n" + "vIOlCsRnKPZzFBQ9RnbDhxSJITRNrw9FDKZJobq7nMWxM4MphQIDAQABo0IwQDAP\n" + "BgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4EFgQUTiJUIBiV\n" + "5uNu5g/6+rkS7QYXjzkwDQYJKoZIhvcNAQELBQADggEBAGBnKJRvDkhj6zHd6mcY\n" + "1Yl9PMWLSn/pvtsrF9+wX3N3KjITOYFnQoQj8kVnNeyIv/iPsGEMNKSuIEyExtv4\n" + "NeF22d+mQrvHRAiGfzZ0JFrabA0UWTW98kndth/Jsw1HKj2ZL7tcu7XUIOGZX1NG\n" + "Fdtom/DzMNU+MeKNhJ7jitralj41E6Vf8PlwUHBHQRFXGU7Aj64GxJUTFy8bJZ91\n" + "8rGOmaFvE7FBcf6IKshPECBV1/MUReXgRPTqh5Uykw7+U0b6LJ3/iyK5S9kJRaTe\n" + "pLiaWN0bfVKfjllDiIGknibVb63dDcY3fe0Dkhvld1927jyNxF1WW6LZZm6zNTfl\n" + "MrY=\n" + "-----END CERTIFICATE-----\n"; diff --git a/libraries/SpotifyEsp32/LICENSE b/libraries/SpotifyEsp32/LICENSE new file mode 100644 index 0000000..0ad25db --- /dev/null +++ b/libraries/SpotifyEsp32/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/libraries/SpotifyEsp32/README.md b/libraries/SpotifyEsp32/README.md new file mode 100644 index 0000000..239ab5e --- /dev/null +++ b/libraries/SpotifyEsp32/README.md @@ -0,0 +1,236 @@ +# SpotifyESP32 + +This library is a wrapper for the [Spotify Web API](https://developer.spotify.com/documentation/web-api/) designed to work with the [ESP32](https://www.espressif.com/en/products/socs/esp32/overview) microcontroller. + +⚠️ **Version 4 Notice:** This release may not be backward compatible with v3.x.x +Some of the API endpoints were removed or renamed, to fully align with the new API provided by Spotify ([Spotify API update Blog](https://developer.spotify.com/blog/2026-02-06-update-on-developer-access-and-platform-security)). + +## Dependencies + +- [ArduinoJson](https://arduinojson.org/) +- [WiFiClientSecure](https://github.com/espressif/arduino-esp32/tree/release/v2.x/libraries/WiFiClientSecure) *(Note: In Arduino-ESP32 v3.x, `WiFiClientSecure` is a compatibility alias for `NetworkClientSecure`. This library uses `WiFiClientSecure` to ensure full compatibility with **PlatformIO**, where v3.x support is still unavailable.)* + +## Setup + + **[YouTube authentication Tutorial for v3 & v4](https://youtu.be/Yy75KzIfqi4)** + +### 1. Create a Spotify Application + +1. Go to the [Spotify Developer Dashboard](https://developer.spotify.com/dashboard/applications). +2. Create a new application and copy your **Client ID** and **Client Secret**. +3. Add the following redirect URI: +4. Enable the **Web API** option. + +### 2. Example: Login without a saved refresh token + +```cpp +#include +#include +#include "SpotifyEsp32.h" + +const char* SSID = "your_ssid"; +const char* PASSWORD = "your_password"; +const char* CLIENT_ID = "your_client_id"; +const char* CLIENT_SECRET = "your_client_secret"; + +// Create an instance of the Spotify class (optional: specify retry count) +Spotify sp(CLIENT_ID, CLIENT_SECRET); + +void setup() { + Serial.begin(115200); + connect_to_wifi(); + + // Optionally set custom scopes the available scopes are listed below + // sp.set_scopes("user-read-playback-state user-modify-playback-state"); + + sp.begin(); + while (!sp.is_auth()) { + sp.handle_client(); // Required for receiving the authorization code + } + + Serial.printf("Authenticated! Refresh token: %s\n", sp.get_user_tokens().refresh_token); +} + +void loop() { + // Your code here +} + +void connect_to_wifi() { + WiFi.begin(SSID, PASSWORD); + Serial.print("Connecting to WiFi..."); + while (WiFi.status() != WL_CONNECTED) { + delay(1000); + Serial.print("."); + } + Serial.println("\nConnected to WiFi!"); +} +``` + +[List of available scopes](https://developer.spotify.com/documentation/web-api/concepts/scopes) + +### 3. Save the Refresh Token + +After logging in via the URL shown in the Serial Monitor, your ESP32 will print a refresh token. +Copy this token and pass it as the third parameter to the constructor. + +```cpp +Spotify sp(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN); +``` + +This way, you won’t have to reauthenticate each time. + +### 4. Setting Tokens at Runtime + +If you prefer setting tokens during runtime (for example, using a web server), you can: +Pass an empty string for the refresh token during initialization. +Later, call get_user_tokens() to retrieve the tokens. +Store them in flash memory (e.g., using [SPIFFS](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/storage/spiffs.html)) + +### 5. Using the Library + +Each API call returns a response object containing: +`response_obj.status_code` → the HTTP status code (or -1 if the request failed before sending) +`response_obj.reply` → the JSON response as a JsonDocument +To print a response: `print_response(response_obj);` +To reduce memory usage, GET requests support **filtered responses** ([Filter tutorial](https://arduinojson.org/news/2020/03/22/version-6-15-0/)): + +```cpp +JsonDocument filter; +filter["item"]["name"] = true; +response res = sp.get_current_playback(filter); +``` + +See the [Spotify Web API Reference](https://developer.spotify.com/documentation/web-api/reference/) for all the possible endpoints. + +## Optimization Options + +To reduce flash usage, disable unneeded endpoints by defining macros before including the library: + +```c++ +#define DISABLE_LIBRARY //Saved items & followed artists +#define DISABLE_PLAYLISTS //Playlist management +#define DISABLE_METADATA //Albums, artists, search, shows, tracks +#define DISABLE_PLAYER //Playback control +#define DISABLE_USER //Profile & top items +#define DISABLE_SIMPLIFIED //Helper convenience functions +``` + +## Helper Functions + +```c++ +// Current playback info +String current_track_name(); +String current_track_id(); +String current_device_id(); +String current_artist_names(); + +// Versions returning pointers (e.g for as parameters for other functions) +char* current_device_id(char* device_id); +char* current_track_id(char* track_id); +char* current_track_name(char* track_name); +char* current_artist_names(char* artist_names); + +// Playback and device info +bool is_playing(); +bool volume_modifyable(); + +// URI helpers +char* convert_id_to_uri(char* id, char* type, char* uri); + +// Current album artwork url +String get_current_album_image_url(int image_size_idx); +``` + +You can also include the namespace: + +```cpp +using namespace spotify_types; +``` + +## Token Management + +- Retrieve stored tokens (useful for saving to flash memory): + + ```cpp + user_tokens tokens = sp.get_user_tokens(); + ``` + + This contains `client_id`, `client_secret`, and `refresh_token`. + +- The library automatically refreshes expired access tokens before making API requests. +You can also refresh manually: + + ```cpp + sp.get_token(); + ``` + + Returns `true` if successfull. + +## Debugging + +The SpotifyEsp32 library features a custom logging system, independent of `esp_log.h`, to assist with effective issue diagnosis. You can configure the logging level using the following method: + +```cpp +sp.set_log_level(spotify_log_level_t spotify_log_level); +``` + +### Available Logging Levels + +The library provides the following logging options to control output verbosity: + +- `SPOTIFY_LOG_NONE`: Disables all logging output. +- `SPOTIFY_LOG_ERROR`: Captures both fatal and non-fatal errors for critical issues. +- `SPOTIFY_LOG_WARN`: Includes warnings alongside error messages. +- `SPOTIFY_LOG_INFO`: Offers additional informational messages about general operation. +- `SPOTIFY_LOG_DEBUG`: Provides detailed debug information for troubleshooting. +- `SPOTIFY_LOG_VERBOSE`: Delivers the highest level of detail with extensive logging. + +### Default Setting + +The default logging level is `SPOTIFY_LOG_NONE`, meaning no logs are generated unless explicitly enabled. + +## Asynchronous Spotify API Calls + +The library supports running Spotify API calls asynchronously using FreeRTOS. This allows your main loop to continue without waiting for a request to finish. + +### Usage + +Define a callback to handle the response: + +```cpp +void handle_callback(response resp) { + print_response(resp); // Handle your response e.g. print it. +} +``` + +Wrap your API call in a lambda and pass it to `async()` along with the callback: + +```cpp +// Example: call a function with arguments +sp.async([&]() { + return sp.get_playlist_items(0, 50, filter_doc); +}, handle_callback); + +// Example: call a function with no arguments +sp.async([&]() { + return sp.get_users_saved_albums(); +}, handle_callback); +``` + +Notes: + +- Any arguments must be captured or bound inside the lambda. +- The callback receives the response object once the async task completes. +- Each async call runs on a separate FreeRTOS task, with a stack size of 8192 bytes by default. + +## Troubleshooting + +- Enable debug mode using the above mentioned function `set_log_level`. +- If requests fail, inspect the returned response or Serial output. +- Test individual endpoints in the [Spotify Web API Console](https://developer.spotify.com/console/).
+- Still having issues? Open an issue in this repository. Or contact me via email. + +## Supported Devices + +- ESP32 WROOM +- Should also work on other ESP32 models. diff --git a/libraries/SpotifyEsp32/examples/getCurrentArtistAndSong/getCurrentArtistAndSong.ino b/libraries/SpotifyEsp32/examples/getCurrentArtistAndSong/getCurrentArtistAndSong.ino new file mode 100644 index 0000000..66a8a24 --- /dev/null +++ b/libraries/SpotifyEsp32/examples/getCurrentArtistAndSong/getCurrentArtistAndSong.ino @@ -0,0 +1,72 @@ +/* + An example of how to authenticate with Spotify without using a refresh token and print Artist and Track via Serial + +In this example your current track will be printed to the serial and as soon as you listen to a new track that tracks information will be printed. + + 15.03.2024 + Created by: Finian Landes + + 16.03.2024 + edited by: Sascha Seidel + * added getting artist and trackname to print it Serial + + 11.10.2025 + edited by: Finian Landes + * Updated login to work with version 3.x.x + + Documentation: https://github.com/FinianLandes/Spotify_Esp32 +*/ +// Include the required libraries +#include +#include +#include + +char* SSID = "YOUR WIFI SSID"; +const char* PASSWORD = "YOUR WIFI PASSWORD"; +const char* CLIENT_ID = "YOUR CLIENT ID FROM THE SPOTIFY DASHBOARD"; +const char* CLIENT_SECRET = "YOUR CLIENT SECRET FROM THE SPOTIFY DASHBOARD"; +const char* REFRESH_TOKEN = "YOUR REFRESH TOKEN"; + +Spotify sp(CLIENT_ID, CLIENT_SECRET); + +void setup() { + Serial.begin(115200); + connect_to_wifi(); + + // Uncomment following line if you want to enable debugging: + // sp.set_log_level(SPOTIFY_LOG_DEBUG); + + sp.begin(); + while(!sp.is_auth()){ + sp.handle_client(); + } + Serial.printf("Authenticated! Refresh token: %s\n", sp.get_user_tokens().refresh_token); +} + +void loop() { + static String lastArtist; + static String lastTrackname; + + String currentArtist = sp.current_artist_names(); + String currentTrackname = sp.current_track_name(); + + if (lastArtist != currentArtist && currentArtist != "Something went wrong" && !currentArtist.isEmpty()) { + lastArtist = currentArtist; + Serial.println("Artist: " + lastArtist); + } + + if (lastTrackname != currentTrackname && currentTrackname != "Something went wrong" && currentTrackname != "null") { + lastTrackname = currentTrackname; + Serial.println("Track: " + lastTrackname); + } +} + +void connect_to_wifi(){ + WiFi.begin(SSID, PASSWORD); + Serial.print("Connecting to WiFi..."); + while (WiFi.status() != WL_CONNECTED) { + delay(1000); + Serial.print("."); + } + Serial.printf("\nConnected to WiFi\n"); +} diff --git a/libraries/SpotifyEsp32/examples/loginExample/loginExample.ino b/libraries/SpotifyEsp32/examples/loginExample/loginExample.ino new file mode 100644 index 0000000..7a25361 --- /dev/null +++ b/libraries/SpotifyEsp32/examples/loginExample/loginExample.ino @@ -0,0 +1,52 @@ +/* + An example of how to authenticate with Spotify. + + This example is useful authenticate in different ways. + + 24.03.2024 + Created by: Finian Landes + + 11.10.2025 + edited by: Finian Landes + * Updated login to work with version 2.x.x + + Documentation: https://github.com/FinianLandes/Spotify_Esp32 +*/ +// Include the required libraries +#include +#include +#include + +char* SSID = "YOUR WIFI SSID"; +const char* PASSWORD = "YOUR WIFI PASSWORD"; +const char* CLIENT_ID = "YOUR CLIENT ID FROM THE SPOTIFY DASHBOARD";//leave these empty if you want to set the credentials during runtime +const char* CLIENT_SECRET = "YOUR CLIENT SECRET FROM THE SPOTIFY DASHBOARD";//leave these empty if you want to set the credentials during runtime + +Spotify sp(CLIENT_ID, CLIENT_SECRET); +//If you already have a refresh token uncomment following line +//Spotify sp(CLIENT_ID, CLIENT_SECRET, REFRESH_TOKEN); + +void setup() { + Serial.begin(115200); + connect_to_wifi(); + // Uncomment following line if you want to enable debugging: + // sp.set_log_level(SPOTIFY_LOG_DEBUG); + sp.begin(); + while(!sp.is_auth()){ + sp.handle_client(); + } + Serial.printf("Authenticated! Refresh token: %s\n", sp.get_user_tokens().refresh_token); +} + +void loop() { + // put your main code here, to run repeatedly: +} +void connect_to_wifi(){ + WiFi.begin(SSID, PASSWORD); + Serial.print("Connecting to WiFi..."); + while (WiFi.status() != WL_CONNECTED) { + delay(1000); + Serial.print("."); + } + Serial.printf("\nConnected to WiFi\n"); +} diff --git a/libraries/SpotifyEsp32/library.properties b/libraries/SpotifyEsp32/library.properties new file mode 100644 index 0000000..73e41f3 --- /dev/null +++ b/libraries/SpotifyEsp32/library.properties @@ -0,0 +1,11 @@ +name = SpotifyEsp32 +version = 4.0.1 +author = Finian Landes, Pia Piekarek, T-vK +maintainer = Finian Landes +sentence = A wrapper for the Spotify API +paragraph = This library provides a client to interact with the Spotify API. It is designed to be used with the ESP32 microcontroller. +category = Communication +url = https://github.com/FinianLandes/SpotifyEsp32 +architectures = * +depends = ArduinoJson +includes = SpotifyEsp32.h diff --git a/libraries/SpotifyEsp32/src/SpotifyEsp32.cpp b/libraries/SpotifyEsp32/src/SpotifyEsp32.cpp new file mode 100644 index 0000000..dfa3b70 --- /dev/null +++ b/libraries/SpotifyEsp32/src/SpotifyEsp32.cpp @@ -0,0 +1,1168 @@ +#include "SpotifyEsp32.h" +#define STACK_PRINT(tag) Serial.printf("[%s] free stack: %d\n", tag, uxTaskGetStackHighWaterMark(NULL)); +spotify_log_level_t _spotify_log_level = SPOTIFY_LOG_INFO; + +namespace spotify_types { + bool SHUFFLE_ON = true; + bool SHUFFLE_OFF = false; + const char* REPEAT_OFF = "off"; + const char* REPEAT_TRACK = "track"; + const char* REPEAT_CONTEXT = "context"; + const char* TYPE_ALBUM = "album"; + const char* TYPE_ARTIST = "artist"; + const char* TYPE_TRACK = "track"; + const char* TYPE_PLAYLIST = "playlist"; + const char* TYPE_SHOW = "show"; + const char* TYPE_EPISODE = "episode"; + const char* TYPE_AUDIOBOOK = "audiobook"; + const char* TOP_TYPE_ARTIST = "artists"; + const char* TOP_TYPE_TRACKS = "tracks"; + const char* GROUP_ALBUM = "album"; + const char* GROUP_SINGLE = "single"; + const char* GROUP_APPEARS_ON = "appears_on"; + const char* GROUP_COMPILATION = "compilation"; + const char* TIME_RANGE_SHORT = "short_term"; + const char* TIME_RANGE_MEDIUM = "medium_term"; + const char* TIME_RANGE_LONG = "long_term"; + const char* FOLLOW_TYPE_ARTIST = "artist"; + const char* FOLLOW_TYPE_USER = "user"; +} + +Spotify::Spotify(const char* client_id, const char* client_secret, int max_num_retry){ + _spotify_log_level = SPOTIFY_LOG_NONE; + _retry = 0; + if(!(client_id && client_secret)){ + _no_credentials = true; + }else{ + strncpy(_client_id, client_id, sizeof(_client_id)); + strncpy(_client_secret, client_secret, sizeof(_client_secret)); + } + if(max_num_retry > 0){ + _max_num_retry = max_num_retry; + } + else{ + _max_num_retry = 1; + } +} + +Spotify::Spotify(const char* client_id, const char* client_secret, const char* refresh_token, int max_num_retry){ + _spotify_log_level = SPOTIFY_LOG_NONE; + _retry = 0; + if(!(client_id && client_secret && refresh_token)){ + _no_credentials = true; + }else{ + strncpy(_client_id, client_id, sizeof(_client_id)); + strncpy(_client_secret, client_secret, sizeof(_client_secret)); + strncpy(_refresh_token, refresh_token, sizeof(_refresh_token)); + } + if(max_num_retry > 0){ + _max_num_retry = max_num_retry; + } + else{ + _max_num_retry = 1; + } +} + +Spotify::~Spotify(){ + SPOTIFY_LOGI(_TAG, "Object Destructed"); +} + +void Spotify::end(){ + delete this; +} + +void Spotify::set_log_level(spotify_log_level_t spotify_log_level){ + _spotify_log_level = spotify_log_level; + SPOTIFY_LOGI(_TAG, "Spotify logging set to (level %d)", spotify_log_level); +} + +void Spotify::begin(){ + SPOTIFY_LOGI(_TAG, "Initializing Spotify client..."); + if (!is_auth()) { + get_random_state(_random_state, _state_len); + SPOTIFY_LOGD(_TAG, "Generated random state: %s", _random_state); + + const char* scopes; + if (_custom_scopes && strlen(_custom_scopes) > 0) { + scopes = _custom_scopes; + SPOTIFY_LOGD(_TAG, "Using custom scopes"); + } else { + scopes = "ugc-image-upload user-read-playback-state user-modify-playback-state user-read-currently-playing app-remote-control streaming playlist-read-private playlist-read-collaborative playlist-modify-private playlist-modify-public user-follow-modify user-follow-read user-read-playback-position user-top-read user-read-recently-played user-library-read user-library-modify user-read-email user-read-private"; + SPOTIFY_LOGD(_TAG, "Using default scopes: all"); + } + + char encoded_redirect_uri[256]; + char encoded_scopes[768]; + urlEncode(_redirect_uri, encoded_redirect_uri, sizeof(encoded_redirect_uri)); + urlEncode(scopes, encoded_scopes, sizeof(encoded_scopes)); + + char url[1024]; + snprintf(url, sizeof(url),"https://accounts.spotify.com/authorize" "?client_id=%s" "&response_type=code" "&redirect_uri=%s" "&scope=%s" "&state=%s", _client_id, encoded_redirect_uri, encoded_scopes, _random_state); + + Serial.println("Open this URL in your browser to authorize:"); + Serial.println(url); + } + SPOTIFY_LOGD(_TAG, "Setting Spotify CA certificate & Timeout"); + _client.setCACert(_spotify_root_ca); + _client.setTimeout(_timeout); + SPOTIFY_LOGI(_TAG, "Spotify client initialized."); + if (is_auth()){ + get_token(); + } +} + +void Spotify::handle_client(){ + static unsigned long start_time = 0; + static bool connecting = false; + static size_t pos = 0; + static bool headers_parsed = false; + static size_t content_length = 0; + static char buffer[2048]; + + if (!connecting) { + SPOTIFY_LOGD(_TAG, "Starting connection to host: %s", _auth_host); + + _client.setInsecure(); + if (!_client.connect(_auth_host, 443)) { + SPOTIFY_LOGE(_TAG, "Connection to host failed!"); + return; + } + + char path[128]; + snprintf(path, sizeof(path), "/api/spotify/get_code?state=%s", _random_state); + _client.print("GET "); + _client.print(path); + _client.print(" HTTP/1.1\r\nHost: "); + _client.print(_auth_host); + _client.print("\r\nConnection: close\r\n\r\n"); + + connecting = true; + start_time = millis(); + pos = 0; + headers_parsed = false; + content_length = 0; + return; + } + + if (millis() - start_time > _timeout) { + SPOTIFY_LOGD(_TAG, "Timeout reached, stopping client."); + _client.stop(); + _client.setCACert(_spotify_root_ca); + connecting = false; + return; + } + + while (_client.available() && pos < sizeof(buffer) - 1) { + buffer[pos++] = _client.read(); + } + + if (!_client.connected() && pos > 0) { + buffer[pos] = '\0'; + + char* body_start = strstr(buffer, "\r\n\r\n"); + if (!body_start) { + SPOTIFY_LOGE(_TAG, "Body start couldn't be found, stopping client."); + _client.stop(); + _client.setCACert(_spotify_root_ca); + connecting = false; + return; + } + body_start += 4; + + JsonDocument doc; + DeserializationError err = deserializeJson(doc, body_start); + if (err) { + SPOTIFY_LOGE(_TAG, "Deserialization failed: %s", err.c_str()); + } else { + const char* code = doc["code"]; + if (code && strlen(code) > 0) { + strncpy(_auth_code, code, sizeof(_auth_code)-1); + _auth_code[sizeof(_auth_code)-1] = '\0'; + SPOTIFY_LOGD(_TAG, "Auth code received: %s", _auth_code); + get_refresh_token(_auth_code, _redirect_uri); + if (is_auth()){ + get_token(); + } + } else { + SPOTIFY_LOGD(_TAG, "Code was empty or null"); + } + } + _client.stop(); + _client.setCACert(_spotify_root_ca); + connecting = false; + } +} + +void Spotify::set_scopes(const char* scopes){ + _custom_scopes = scopes; +} + +void Spotify::get_random_state(char* state, size_t len){ + const char charset[] = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + const char prefix[] = "ESP32-"; + size_t prefix_len = strlen(prefix); + strcpy(state, prefix); + size_t max_random = len - prefix_len; + for (size_t i = 0; i < max_random; i++) { + int key = esp_random() % (sizeof(charset) - 1); + state[prefix_len + i] = charset[key]; + } + state[prefix_len + max_random] = '\0'; +} + +bool Spotify::has_access_token(){ + return _access_token[0] == '\n'; +} + +bool Spotify::get_access_token(){ + return get_token(); +} + +bool Spotify::is_auth(){ + return !(strcmp(_refresh_token, "") == 0 || !_refresh_token || strcmp(_client_id, "") == 0 || !_client_id); +} + +//Basic functions +response Spotify::RestApi(const char* rest_url, const char* type, int payload_size, const char* payload, JsonDocument filter, const char* content_type) { + response response_obj; + init_response(&response_obj); + _client.stop(); + + if (!_client.connect(_host, 443)) { + SPOTIFY_LOGE(_TAG, "Connection failed"); + deserializeJson(response_obj.reply, "Connection failed"); + return response_obj; + } + + SPOTIFY_LOGV(_TAG, "Creating %s request to %s", type, rest_url); + + _client.print(type); _client.print(" "); _client.print(rest_url); _client.println(" HTTP/1.1"); + _client.print("Host: "); _client.println(_host); + _client.println("User-Agent: ESP32"); + _client.println("Connection: close"); + _client.println("Accept: application/json"); + _client.print("Authorization: Bearer "); _client.println(_access_token); + _client.print("Content-Type: "); _client.println(content_type); + + if (strcmp(type, "GET") == 0) { + _client.println(); + } else if (strcmp(type, "PUT") == 0 || strcmp(type, "POST") == 0 || strcmp(type, "DELETE") == 0) { + SPOTIFY_LOGV(_TAG, "Sending payload %s with size %i ", payload ? payload : "NULL", payload_size); + _client.print("Content-Length: "); _client.println(payload_size); // ← fixed + _client.println(); + if (payload_size > 0 && payload != nullptr) { + if (_client.write((const uint8_t*)payload, payload_size) != payload_size) { + SPOTIFY_LOGE(_TAG, "Failed to send payload"); + deserializeJson(response_obj.reply, "Payload send failed"); + response_obj.status_code = -1; + _client.stop(); + return response_obj; + } + } + } + _client.flush(); + + header_resp header_data = process_headers(); + response_obj.status_code = header_data.http_code; + + + SPOTIFY_LOGV(_TAG, "Header Data: Code %i, Content of length %i and type %s, Error: %s", header_data.http_code, header_data.content_length, header_data.content_type.c_str(), header_data.error.c_str()); + + String str_response; + JsonDocument response_doc; + + if (!valid_http_code(header_data.http_code)) response_doc = process_response(header_data); + else response_doc = process_response(header_data, filter); + + serializeJson(response_doc, str_response); + + SPOTIFY_LOGD(_TAG, "%s \"%s\" Status: %i", type, extract_endpoint(rest_url).c_str(), header_data.http_code); + SPOTIFY_LOGD(_TAG, "Reply: %s", str_response.c_str()); + + if (_retry <= _max_num_retry && !valid_http_code(header_data.http_code)) { + SPOTIFY_LOGD(_TAG, "Retrying"); + String message = response_doc["error"]["message"].as(); + _retry++; + if (message == "Only valid bearer authentication supported") { + _client.stop(); + if (get_token()) { + return RestApi(rest_url, type, payload_size, payload, filter, content_type); + } else { + SPOTIFY_LOGE(_TAG, "Failed to get new access token"); + deserializeJson(response_obj.reply, "Failed to get token"); + response_obj.status_code = -1; + } + } else { + response_obj.reply = response_doc; + } + } else { + response_obj.reply = response_doc; + } + + _client.stop(); + _retry = 0; + return response_obj; +} + +response Spotify::RestApiPut(const char* rest_url, int payload_size, const char* payload, const char* content_type){ + return RestApi(rest_url, "PUT", payload_size, payload, JsonDocument(), content_type); +} + +response Spotify::RestApiGet(const char* rest_url, JsonDocument filter){ + return RestApi(rest_url, "GET", 0, "", filter); +} + +response Spotify::RestApiPost(const char* rest_url, int payload_size, const char* payload){ + return RestApi(rest_url, "POST", payload_size, payload); +} + +response Spotify::RestApiDelete(const char* rest_url, int payload_size, const char* payload){ + return RestApi(rest_url, "DELETE", payload_size, payload); +} + +bool Spotify::token_base_req(const char* payload, size_t payload_len){ + if (!_client.connect(_token_host, 443)) { + SPOTIFY_LOGE(_TAG, "Faild to connect to host for token request"); + return false; + } + char auth_raw[128]; + snprintf(auth_raw, sizeof(auth_raw), "%s:%s", _client_id, _client_secret); + char auth_base64[192]; + size_t out_len = 0; + int ret = mbedtls_base64_encode((unsigned char*)auth_base64, sizeof(auth_base64), &out_len, (const unsigned char*)auth_raw, strlen(auth_raw)); + auth_base64[strlen(auth_base64)] = '\0'; + + _client.println("POST /api/token HTTP/1.1"); + _client.println("Host: " + String(_token_host)); + _client.println("User-Agent: ESP32"); + _client.println("Connection: close"); + _client.println("Content-Type: application/x-www-form-urlencoded"); + _client.print("Authorization: Basic "); + _client.println(auth_base64); + _client.print("Content-Length: "); + _client.println((int)payload_len); + _client.println(); + _client.write((const uint8_t*)payload, payload_len); + _client.println(); + _client.flush(); + return true; +} + +bool Spotify::get_refresh_token(const char* auth_code, const char* redirect_uri){ + bool reply = false; + char payload[1024]; + snprintf(payload, sizeof(payload), "grant_type=authorization_code&code=%s&redirect_uri=%s", auth_code, redirect_uri); + + if (!token_base_req(payload, strlen(payload))) { + SPOTIFY_LOGE(_TAG, "Refresh token connection failed"); + _client.stop(); + return false; + } + + header_resp header_data = process_headers(); + if (header_data.http_code == -1) { + _client.stop(); + SPOTIFY_LOGE(_TAG, "Refresh token header processing failed"); + return false; + } + + JsonDocument filter; + filter["refresh_token"] = true; + JsonDocument response = process_response(header_data, filter); + + if (!response.isNull() && !response["refresh_token"].isNull()) { + reply = true; + strncpy(_refresh_token, response["refresh_token"].as(), sizeof(_refresh_token)); + _refresh_token[sizeof(_refresh_token) - 1] = '\0'; + } + String str_response; + serializeJson(response, str_response); + SPOTIFY_LOGD(_TAG, "POST \"refresh token\" Status: %i", header_data.http_code); + SPOTIFY_LOGD(_TAG, "Reply: %s", str_response.c_str()); + + _client.stop(); + return reply; +} + +bool Spotify::get_token() { + bool reply = false; + char payload[860]; + int written = snprintf(payload, sizeof(payload), "grant_type=refresh_token&refresh_token=%s", _refresh_token); + if (written < 0 || written >= (int)sizeof(payload)) { + return false; + } + + if (!token_base_req(payload, strlen(payload))) { + SPOTIFY_LOGE(_TAG, "Access token connection failed"); + _client.stop(); + return false; + } + header_resp header_data = process_headers(); + if(header_data.http_code == -1){ + SPOTIFY_LOGE(_TAG, "Access token invalid or empty headers received"); + _client.stop(); + return false; + } + JsonDocument filter; + filter["access_token"] = true; + JsonDocument response = process_response(header_data, filter); + if(!response.isNull() && !response["access_token"].isNull()){ + reply = true; + strncpy(_access_token, response["access_token"].as(), sizeof(_access_token)); + } + String str_response; + serializeJson(response, str_response); + SPOTIFY_LOGD(_TAG, "POST \"access token\" Status: %i", header_data.http_code); + SPOTIFY_LOGD(_TAG, "Reply: %s", str_response.c_str()); + _client.stop(); + return reply; +} + +bool Spotify::valid_http_code(int code){ + return (code >= 200 && code <= 299); +} + +header_resp Spotify::process_headers(){ + header_resp response; + response.http_code = -1; + response.content_length = 0; + response.error = ""; + bool can_break = false; + unsigned long last_data_receive = millis(); + while (_client.connected()) { + String line = _client.readStringUntil('\n'); + if (line != "") { + last_data_receive = millis(); + } + else if (line == "" && millis() - last_data_receive > _timeout) { + response.http_code = -1; + response.error = "Timeout receiving headers"; + SPOTIFY_LOGE(_TAG, "Timeout while receiving headers"); + break; + } + SPOTIFY_LOGV(_TAG, "Header: %s", line.c_str()); + + // Parse HTTP response code + if (line.startsWith("HTTP")) { + response.http_code = line.substring(9, 12).toInt(); + } + // Parse Content-Type + else if(line.startsWith("Content-Type") || line.startsWith("content-type")){ + response.content_type = line.substring(14); + } + // Parse Content-Length + else if (line.startsWith("Content-Length") || line.startsWith("content-length")) { + response.content_length = line.substring(16).toInt(); + can_break = true; + } + // If can_break is true, find the end of headers and break + else if(can_break){ + _client.find("\r\n\r\n"); + break; + } + // If line is a carriage return, indicating end of headers, break + if (line == "\r") { + break; + } + } + + // If http_code is -1 and no error has been set, set a default error + if(response.http_code == -1 && response.error == ""){ + response.error = "Client disconnected while receiving headers"; + } + + return response; +} + +JsonDocument Spotify::process_response(header_resp header_data, JsonDocument filter){ + JsonDocument response; + SPOTIFY_LOGD(_TAG, "Filter: %s", filter.isNull() ? "Off" : "On"); + + if (header_data.http_code == 204 || header_data.content_length == 0) { + response["message"] = "No Content"; + return response; + } + + if (header_data.http_code == -1) { + response["message"] = header_data.error.isEmpty() ? "Client disconnected" : header_data.error; + return response; + } + + String raw_response = ""; + size_t bytes_read = 0; + unsigned long start = millis(); + + while (bytes_read < header_data.content_length && millis() - start < _timeout) { + if (_client.available()) { + char buf[128]; + int len = _client.readBytes(buf, min(sizeof(buf), header_data.content_length - bytes_read)); + if (len > 0) { + raw_response += String(buf, len); + bytes_read += len; + } else if (len == 0) { + SPOTIFY_LOGV(_TAG, "No more data available, stopping read"); + break; + } + } + } + + SPOTIFY_LOGV(_TAG, "Raw response body: '%s' (read %d/%d bytes)", raw_response.c_str(), bytes_read, header_data.content_length); + + if (header_data.content_type.indexOf("application/json") != -1 && raw_response.length() > 0) { + DeserializationError err; + JsonDocument effective_filter; + if (!filter.isNull()) { + effective_filter = filter; + effective_filter["error"] = true; + } + if (effective_filter.isNull()) { + err = deserializeJson(response, raw_response); + } else { + err = deserializeJson(response, raw_response, DeserializationOption::Filter(effective_filter)); + } + if (err) { + SPOTIFY_LOGE(_TAG, "Error parsing JSON: %s", err.c_str()); + response["message"] = String("JSON parsing failed: ") + err.c_str(); + } + } else { + response["message"] = raw_response.length() ? raw_response : "No Body"; + } + + return response; +} + +void Spotify::init_response(response* response_obj){ + response_obj -> status_code = -1; + deserializeJson(response_obj -> reply, "If you see this, something went wrong"); +} + +String Spotify::extract_endpoint(const char* url) { + String urlStr(url); + int startPos = urlStr.indexOf("https://api.spotify.com/v1/"); + if (startPos >= 0) { + startPos += String("https://api.spotify.com/v1/").length(); + int endPos = urlStr.indexOf('?', startPos); + if (endPos >= 0) { + return String(urlStr.substring(startPos, endPos).c_str()); + }else{ + return String(urlStr.substring(startPos).c_str()); + } + } + return ""; +} + +#ifndef DISABLE_LIBRARY +response Spotify::save_items_to_library(int size, const char** uris){ + char url[_standard_url_size + _max_char_size]; + char uris_param[_max_char_size]; + int pos = snprintf(uris_param, sizeof(uris_param), "uris="); + char temp[256]; + for(int i = 0; i < size; i++){ + if(i > 0) pos += snprintf(uris_param + pos, sizeof(uris_param) - pos, ","); + urlEncode(uris[i], temp, sizeof(temp)); + pos += snprintf(uris_param + pos, sizeof(uris_param) - pos, "%s", temp); + } + snprintf(url, sizeof(url), "%sme/library?%s", _base_url, uris_param); + return RestApiPut(url); +} + +response Spotify::remove_items_from_library(int size, const char** uris){ + char url[_standard_url_size + _max_char_size]; + char uris_param[_max_char_size]; + int pos = snprintf(uris_param, sizeof(uris_param), "uris="); + char temp[256]; + for (int i = 0; i < size; i++) { + if (i > 0) pos += snprintf(uris_param + pos, sizeof(uris_param) - pos, ","); + urlEncode(uris[i], temp, sizeof(temp)); + pos += snprintf(uris_param + pos, sizeof(uris_param) - pos, "%s", temp); + } + snprintf(url, sizeof(url), "%sme/library?%s", _base_url, uris_param); + return RestApiDelete(url); +} + +response Spotify::check_users_saved_items(int size, const char** uris, JsonDocument filter){ + char url[_standard_url_size + _max_char_size]; + char uris_param[_max_char_size]; + int pos = snprintf(uris_param, sizeof(uris_param), "uris="); + char temp[256]; + for (int i = 0; i < size; i++) { + if (i > 0) pos += snprintf(uris_param + pos, sizeof(uris_param) - pos, ","); + urlEncode(uris[i], temp, sizeof(temp)); + pos += snprintf(uris_param + pos, sizeof(uris_param) - pos, "%s", temp); + } + snprintf(url, sizeof(url), "%sme/library/contains?%s", _base_url, uris_param); + return RestApiGet(url, filter); +} + +response Spotify::get_users_saved_albums(int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/albums?limit=%d&offset=%d", _base_url, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_users_saved_audiobooks(int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/audiobooks?limit=%d&offset=%d", _base_url, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_users_saved_episodes(int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/episodes?limit=%d&offset=%d", _base_url, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_users_saved_shows(int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/shows?limit=%d&offset=%d", _base_url, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_users_saved_tracks(int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/tracks?limit=%d&offset=%d", _base_url, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_followed_artists(int limit, const char* after, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/following?type=artist&limit=%d", _base_url, limit); + if (after != nullptr) { + char encoded[256]; + urlEncode(after, encoded, sizeof(encoded)); + char tmp[300]; + snprintf(tmp, sizeof(tmp), "&after=%s", encoded); + strncat(url, tmp, sizeof(url) - strlen(url) - 1); + } + return RestApiGet(url, filter); +} +#endif + +#ifndef DISABLE_PLAYLISTS +response Spotify::change_playlist_details(const char* playlist_id, JsonDocument details){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s", _base_url, playlist_id); + char payload[512]; + serializeJson(details, payload, sizeof(payload)); + int payload_size = strlen(payload); + return RestApiPut(url, payload_size, payload); +} + +response Spotify::create_playlist(const char* name, const char* description, bool is_public, bool collaborative){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/playlists", _base_url); + + JsonDocument doc; + doc["name"] = name; + if (description != nullptr) doc["description"] = description; + doc["public"] = is_public; + doc["collaborative"] = collaborative; + + char payload[256]; + serializeJson(doc, payload, sizeof(payload)); + int payload_size = strlen(payload); + return RestApiPost(url, payload_size, payload); +} + +response Spotify::get_current_users_playlists(int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/playlists?limit=%d&offset=%d", _base_url, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_playlist(const char* playlist_id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s", _base_url, playlist_id); + return RestApiGet(url, filter); +} + +response Spotify::get_playlist_cover_image(const char* playlist_id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s/images", _base_url, playlist_id); + return RestApiGet(url, filter); +} + +response Spotify::upload_custom_playlist_cover_image(const char* playlist_id, const char* image_data){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s/images", _base_url, playlist_id); + int payload_size = strlen(image_data); + return RestApiPut(url, payload_size, image_data, "image/jpeg"); +} + +response Spotify::add_items_to_playlist(const char* playlist_id, int size, const char** uris, int position){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s/items", _base_url, playlist_id); + if (position >= 0) { + char tmp[32]; + snprintf(tmp, sizeof(tmp), "?position=%d", position); + strncat(url, tmp, sizeof(url) - strlen(url) - 1); + } + + JsonDocument doc; + JsonArray arr = doc["uris"].to(); + for (int i = 0; i < size; i++) arr.add(uris[i]); + + char payload[_max_char_size]; + serializeJson(doc, payload, sizeof(payload)); + int payload_size = strlen(payload); + return RestApiPost(url, payload_size, payload); +} + +response Spotify::remove_playlist_items(const char* playlist_id, int size, const char** uris, const char* snapshot_id){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s/items", _base_url, playlist_id); + + JsonDocument doc; + JsonArray arr = doc["items"].to(); + for (int i = 0; i < size; i++) { + if (uris[i]) { + JsonObject obj = arr.add(); + obj["uri"] = uris[i]; + } + } + if (snapshot_id != nullptr) doc["snapshot_id"] = snapshot_id; + + char payload[_max_char_size]; + serializeJson(doc, payload, sizeof(payload)); + int payload_size = strlen(payload); + return RestApiDelete(url, payload_size, payload); +} + +response Spotify::update_playlist_items(const char* playlist_id, int size, const char** uris, int range_start, int insert_before, int range_length, const char* snapshot_id){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s/items", _base_url, playlist_id); + + JsonDocument doc; + if (size > 0) { + JsonArray arr = doc["uris"].to(); + for (int i = 0; i < size; i++) arr.add(uris[i]); + } else { + doc["range_start"] = range_start; + doc["insert_before"] = insert_before; + doc["range_length"] = range_length; + } + if (snapshot_id != nullptr) doc["snapshot_id"] = snapshot_id; + + char payload[_max_char_size]; + serializeJson(doc, payload, sizeof(payload)); + int payload_size = strlen(payload); + return RestApiPut(url, payload_size, payload); +} + +response Spotify::get_playlist_items(const char* playlist_id, int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%splaylists/%s/items?limit=%d&offset=%d", _base_url, playlist_id, limit, offset); + return RestApiGet(url, filter); +} +#endif + +#ifndef DISABLE_METADATA +response Spotify::get_album(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%salbums/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::get_album_tracks(const char* id, int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%salbums/%s/tracks?limit=%d&offset=%d", _base_url, id, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_artist(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sartists/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::get_artists_albums(const char* id, int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sartists/%s/albums?limit=%d&offset=%d", _base_url, id, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_audiobook(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%saudiobooks/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::get_audiobook_chapters(const char* id, int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%saudiobooks/%s/chapters?limit=%d&offset=%d", _base_url, id, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_chapter(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%schapters/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::get_episode(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sepisodes/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::get_show(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sshows/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::get_show_episodes(const char* id, int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sshows/%s/episodes?limit=%d&offset=%d", _base_url, id, limit, offset); + return RestApiGet(url, filter); +} + +response Spotify::get_track(const char* id, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%stracks/%s", _base_url, id); + return RestApiGet(url, filter); +} + +response Spotify::search_for_item(const char* q, const char* type, int limit, int offset, JsonDocument filter){ + char url[_standard_url_size]; + char encoded_q[512]; + urlEncode(q, encoded_q, sizeof(encoded_q)); + snprintf(url, sizeof(url), "%ssearch?q=%s&type=%s&limit=%d&offset=%d", _base_url, encoded_q, type, limit, offset); + return RestApiGet(url, filter); +} +#endif + +#ifndef DISABLE_USER +response Spotify::get_current_users_profile(JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme", _base_url); + return RestApiGet(url, filter); +} + +response Spotify::get_users_top_items(const char* type, int limit, int offset, const char* time_range, JsonDocument filter){ + char url[_standard_url_size * 2]; + int len = snprintf(url, sizeof(url),"%sme/top/%s?limit=%d&offset=%d",_base_url, type, limit, offset); + if (time_range != nullptr && len < sizeof(url)) { + snprintf(url + len, sizeof(url) - len, "&time_range=%s", time_range); + } + return RestApiGet(url, filter); +} +#endif + +#ifndef DISABLE_PLAYER +response Spotify::add_item_to_queue(const char* uri, const char* device_id){ + char url[_max_char_size]; + char encoded_uri[256]; + urlEncode(uri, encoded_uri, sizeof(encoded_uri)); + + if (device_id == nullptr) { + snprintf(url, sizeof(url), "%sme/player/queue?uri=%s", _base_url, encoded_uri); + } else { + snprintf(url, sizeof(url), "%sme/player/queue?uri=%s&device_id=%s", _base_url, encoded_uri, device_id); + } + return RestApiPost(url); +} + +response Spotify::get_available_devices(JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/devices", _base_url); + return RestApiGet(url, filter); +} + +response Spotify::get_currently_playing_track(JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/currently-playing", _base_url); + return RestApiGet(url, filter); +} + +response Spotify::get_playback_state(JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player", _base_url); + return RestApiGet(url, filter); +} + +response Spotify::get_recently_played_tracks(int limit, JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/recently-played?limit=%d", _base_url, limit); + return RestApiGet(url, filter); +} + +response Spotify::get_users_queue(JsonDocument filter){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/queue", _base_url); + return RestApiGet(url, filter); +} + +response Spotify::pause_playback(){ + char url[_standard_url_size]; + snprintf(url, sizeof(url),"%sme/player/pause", _base_url); + return RestApiPut(url); +} + +response Spotify::seek_to_position(int position_ms){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/seek?position_ms=%d", _base_url, position_ms); + return RestApiPut(url); +} + +response Spotify::set_repeat_mode(const char* state){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/repeat?state=%s", _base_url, state); + return RestApiPut(url); +} + +response Spotify::set_volume(int volume_percent){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/volume?volume_percent=%d", _base_url, volume_percent); + return RestApiPut(url); +} + +response Spotify::skip_to_next(){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/next", _base_url); + return RestApiPost(url); +} + +response Spotify::skip_to_previous(){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/previous", _base_url); + return RestApiPost(url); +} + +response Spotify::start_a_users_playback(const char* device_id){ + char url[_standard_url_size]; + if (device_id == nullptr) + snprintf(url, sizeof(url), "%sme/player/play", _base_url); + else + snprintf(url, sizeof(url), "%sme/player/play?device_id=%s", _base_url, device_id); + return RestApiPut(url); +} + +response Spotify::toggle_shuffle(bool state){ + char url[_standard_url_size]; + snprintf(url, sizeof(url), "%sme/player/shuffle?state=%s", _base_url, state ? "true" : "false"); + return RestApiPut(url); +} + +response Spotify::transfer_playback(const char* device_id){ + char url[_standard_url_size]; + snprintf(url, sizeof(url),"%sme/player", _base_url); + char payload[100]; + int payload_size = 0; + snprintf(payload,sizeof(payload), "{\"device_ids\":[\"%s\"]}", device_id); + payload_size = strlen(payload); + return RestApiPut(url, payload_size, payload); +} +#endif + +#ifndef DISABLE_SIMPLIFIED +String Spotify::get_current_album_image_url(int image_size_idx){ + String album_url = "Something went wrong"; + JsonDocument filter; + filter["item"]["album"]["images"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + JsonArray images = data.reply["item"]["album"]["images"].as(); + if (!images.isNull() && image_size_idx >= 0 && image_size_idx < images.size()) { + album_url = images[image_size_idx]["url"].as(); + } + } + return album_url; +} + +String Spotify::current_track_name(){ + String track_name = "Something went wrong"; + JsonDocument filter; + filter["item"]["name"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + track_name = data.reply["item"]["name"].as(); + } + return track_name; +} + +String Spotify::current_track_id(){ + String track_id = "Something went wrong"; + JsonDocument filter; + filter["item"]["id"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + track_id = data.reply["item"]["id"].as(); + } + return track_id; +} + +String Spotify::current_device_id(){ + String device_id = "Something went wrong"; + JsonDocument filter; + JsonObject obj = filter["devices"].add(); + obj["id"] = true; + obj["is_active"] = true; + response data = get_available_devices(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + JsonArray devices = data.reply["devices"].as(); + for (JsonVariant device : devices) { + JsonObject deviceObj = device.as(); + if (deviceObj["is_active"].as()) { + device_id = deviceObj["id"].as(); + break; + } + } + } + return device_id; +} + +String Spotify::current_artist_names(){ + String artist_names = "Something went wrong"; + JsonDocument filter; + filter["item"]["artists"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + JsonArray array = data.reply["item"]["artists"]; + int len = array.size(); + artist_names = ""; + for (int i = 0; i(); + if (i(); + obj["id"] = true; + obj["is_active"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + JsonArray devices = data.reply["devices"].as(); + for (JsonVariant device : devices) { + JsonObject deviceObj = device.as(); + if (deviceObj["is_active"].as()) { + strcpy(device_id, deviceObj["id"].as().c_str()); + break; + } + } + } + return device_id; +} + +char* Spotify::current_track_name(char * track_name){ + JsonDocument filter; + filter["item"]["name"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + strcpy(track_name, data.reply["item"]["name"].as().c_str()); + } + return track_name; +} + +char* Spotify::current_track_id(char * track_id){ + JsonDocument filter; + filter["item"]["id"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + strcpy(track_id, data.reply["item"]["id"].as().c_str()); + } + return track_id; +} + +char* Spotify::current_artist_names(char * artist_names){ + JsonDocument filter; + filter["item"]["artists"] = true; + response data = get_currently_playing_track(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + JsonArray array = data.reply["item"]["artists"]; + int len = array.size(); + artist_names[0] = '\0'; + for (int i = 0; i().c_str()); + if (i(); + } + return is_playing; +} + +bool Spotify::volume_modifyable(){ + bool volume_modifyable = false; + JsonDocument filter; + filter["device"]["supports_volume"] = true; + response data = get_playback_state(filter); + if(valid_http_code(data.status_code) && !data.reply.isNull()){ + volume_modifyable = data.reply["device"]["supports_volume"]; + } + return volume_modifyable; +} +#endif + +char* Spotify::convert_id_to_uri(const char* id, const char* type,char * uri){ + snprintf(uri,sizeof(uri), "spotify:%s:%s", type, id); + return uri; +} + +user_tokens Spotify::get_user_tokens(){ + user_tokens tokens; + tokens.client_id = strdup(_client_id); + tokens.refresh_token = strdup(_refresh_token); + tokens.client_secret = strdup(_client_secret); + return tokens; +} + +void print_response(response response_obj){ + Serial.printf("Status: %d\n", response_obj.status_code); + Serial.print("Reply: "); + serializeJsonPretty(response_obj.reply, Serial); + Serial.println(); +} + +void Spotify::urlEncode(const char* src, char* dest, size_t destSize){ + size_t j = 0; + for(size_t i = 0; src[i] != '\0' && j < destSize - 1; ++i){ + unsigned char c = src[i]; + if((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || c == '-' || c == '_' || c == '.' || c == '~'){ + dest[j++] = c; + }else{ + if(j + 3 < destSize){ + snprintf(dest + j, 4, "%%%02X", c); + j += 3; + }else{ + break; + } + } + } + dest[j] = '\0'; +} + +void Spotify::async(std::function fn, AsyncCallback callback) { + struct TaskParam { + std::function fn; + AsyncCallback callback; + }; + TaskParam* param = new TaskParam{fn, callback}; + + xTaskCreatePinnedToCore( + [](void* pv) { + TaskParam* p = (TaskParam*)pv; + response r = p->fn(); + if (p->callback) p->callback(r); + delete p; + vTaskDelete(NULL); + }, + "SpotifyAsync", 8192, param, 1, NULL, 1); +} \ No newline at end of file diff --git a/libraries/SpotifyEsp32/src/SpotifyEsp32.h b/libraries/SpotifyEsp32/src/SpotifyEsp32.h new file mode 100644 index 0000000..9e463ff --- /dev/null +++ b/libraries/SpotifyEsp32/src/SpotifyEsp32.h @@ -0,0 +1,723 @@ +#ifndef SpotifyEsp32 +#define SpotifyEsp32 +//Uncomment the following lines to disable certain features +//#define DISABLE_LIBRARY +//#define DISABLE_PLAYLISTS +//#define DISABLE_METADATA +//#define DISABLE_PLAYER +//#define DISABLE_USER +//#define DISABLE_SIMPLIFIED + + +#include +#include +#include +#include +#include "mbedtls/sha256.h" +#include "mbedtls/base64.h" + + +#ifndef DISABLE_TRACKS +#include +#endif + +typedef enum { + SPOTIFY_LOG_NONE = 0, + SPOTIFY_LOG_ERROR, + SPOTIFY_LOG_WARN, + SPOTIFY_LOG_INFO, + SPOTIFY_LOG_DEBUG, + SPOTIFY_LOG_VERBOSE +} spotify_log_level_t; + +extern spotify_log_level_t _spotify_log_level; + +#define SPOTIFY_LOGV(tag, format, ...) do { if (_spotify_log_level >= SPOTIFY_LOG_VERBOSE) { Serial.printf("[%6lu][V][%s:%d][%s] " format "\n", millis(), __FILE__, __LINE__, __FUNCTION__, ##__VA_ARGS__); } } while (0) +#define SPOTIFY_LOGD(tag, format, ...) do { if (_spotify_log_level >= SPOTIFY_LOG_DEBUG) { Serial.printf("[%6lu][D][%s:%d][%s] " format "\n", millis(), __FILE__, __LINE__, __FUNCTION__, ##__VA_ARGS__); } } while (0) +#define SPOTIFY_LOGI(tag, format, ...) do { if (_spotify_log_level >= SPOTIFY_LOG_INFO) { Serial.printf("[%6lu][I][%s:%d][%s] " format "\n", millis(), __FILE__, __LINE__, __FUNCTION__, ##__VA_ARGS__); } } while (0) +#define SPOTIFY_LOGW(tag, format, ...) do { if (_spotify_log_level >= SPOTIFY_LOG_WARN) { Serial.printf("[%6lu][W][%s:%d][%s] " format "\n", millis(), __FILE__, __LINE__, __FUNCTION__, ##__VA_ARGS__); } } while (0) +#define SPOTIFY_LOGE(tag, format, ...) do { if (_spotify_log_level >= SPOTIFY_LOG_ERROR) { Serial.printf("[%6lu][E][%s:%d][%s] " format "\n", millis(), __FILE__, __LINE__, __FUNCTION__, ##__VA_ARGS__); } } while (0) + + +namespace spotify_types { + extern bool SHUFFLE_ON; + extern bool SHUFFLE_OFF; + extern const char* REPEAT_OFF; + extern const char* REPEAT_TRACK; + extern const char* REPEAT_CONTEXT; + extern const char* TYPE_ALBUM; + extern const char* TYPE_ARTIST; + extern const char* TYPE_TRACK; + extern const char* TYPE_PLAYLIST; + extern const char* TYPE_SHOW; + extern const char* TYPE_EPISODE; + extern const char* TYPE_AUDIOBOOK; + extern const char* TOP_TYPE_ARTIST; + extern const char* TOP_TYPE_TRACKS; + extern const char* GROUP_ALBUM; + extern const char* GROUP_SINGLE; + extern const char* GROUP_APPEARS_ON; + extern const char* GROUP_COMPILATION; + extern const char* TIME_RANGE_SHORT; + extern const char* TIME_RANGE_MEDIUM; + extern const char* TIME_RANGE_LONG; + extern const char* FOLLOW_TYPE_ARTIST; + extern const char* FOLLOW_TYPE_USER; + constexpr size_t SIZE_OF_ID = 40; + constexpr size_t SIZE_OF_URI = 50; + constexpr size_t SIZE_OF_SECRET_ID = 100; + constexpr size_t SIZE_OF_REFRESH_TOKEN = 300; +} + +/// @brief Response object containing http status code and reply +typedef struct{ + int status_code; + JsonDocument reply; +} response; +/// @brief User token object containing user id, secret and refresh token +typedef struct{ + const char* client_id; + const char* client_secret; + const char* refresh_token; +} user_tokens; +/// @brief HTTPS header object +typedef struct{ + int http_code; + size_t content_length; + String content_type; + String error; +} header_resp; + +typedef void (*AsyncCallback)(response); + + +/// @brief Print response object +/// @param response_obj Response object to print +void print_response(response response_obj); + +class Spotify { + public: + /// @brief Creates a Spotify client without a refresh token. + /// @param client_id Null-terminated Client ID obtained from the Spotify Developer Dashboard. + /// The string must remain valid for the lifetime of the Spotify object. + /// If credentials are set later at runtime, provide a writable buffer with sufficient size. + /// @param client_secret Null-terminated Client Secret obtained from the Spotify Developer Dashboard. + /// The string must remain valid for the lifetime of the Spotify object. + /// If credentials are set later at runtime, provide a writable buffer with sufficient size. + /// @param max_num_retry Maximum number of automatic retries per HTTP request (default: 3). + Spotify(const char* client_id, const char* client_secret, int max_num_retry = 3); + + /// @brief Creates a Spotify client with an existing refresh token. + /// @param client_id Null-terminated Client ID obtained from the Spotify Developer Dashboard. + /// The string must remain valid for the lifetime of the Spotify object. + /// @param client_secret Null-terminated Client Secret obtained from the Spotify Developer Dashboard. + /// The string must remain valid for the lifetime of the Spotify object. + /// @param refresh_token Null-terminated refresh token previously obtained via OAuth. + /// The string must remain valid for the lifetime of the Spotify object. + /// If set later at runtime, provide a writable buffer with sufficient size. + /// @param max_num_retry Maximum number of automatic retries per HTTP request (default: 3). + Spotify(const char* client_id, const char* client_secret, const char* refresh_token, int max_num_retry = 3); + + /// @brief Destructor. Releases allocated resources and stops internal services if running. + ~Spotify(); + + /// @brief Starts the local authentication server and initializes the OAuth flow. + /// Must be called before attempting authenticated API requests. + void begin(); + + /// @brief Handles incoming HTTP client requests required for OAuth authentication. + /// Should be called regularly inside the main loop. + void handle_client(); + + /// @brief Checks whether a valid access token is available. + /// @return true if authentication is complete and a valid access token is present. + bool is_auth(); + + #ifndef DISABLE_LIBRARY + /// @brief Save one or more items (tracks, albums, episodes, shows, audiobooks, artists, users, playlists) to the current user’s library. + /// @param size Number of URIs in the uris array. + /// @param uris Array of Spotify URIs (max 40). + /// @return response object containing http status code and reply. + response save_items_to_library(int size, const char** uris); + + /// @brief Remove one or more items from the current user’s library. + /// @param size Number of URIs in the uris array. + /// @param uris Array of Spotify URIs (max 40). + /// @return response object containing http status code and reply. + response remove_items_from_library(int size, const char** uris); + + /// @brief Check if one or more items are already saved in the current user’s library. + /// @param size Number of URIs in the uris array. + /// @param uris Array of Spotify URIs (max 40). + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply + response check_users_saved_items(int size, const char** uris, JsonDocument filter = JsonDocument()); + + /// @brief Get a list of the albums saved in the current user’s library. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_saved_albums(int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get a list of the audiobooks saved in the current user’s library. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_saved_audiobooks(int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get a list of the episodes saved in the current user’s library. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_saved_episodes(int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get a list of the shows saved in the current user’s library. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_saved_shows(int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get a list of the tracks saved in the current user’s library. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_saved_tracks(int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get the list of artists followed by the current user. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param after The cursor to use for pagination (Optional). + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_followed_artists(int limit = 10, const char* after = nullptr, JsonDocument filter = JsonDocument()); + #endif + + #ifndef DISABLE_PLAYLISTS + /// @brief Change a playlist’s name, description, or public/collaborative state. + /// @param playlist_id The Spotify ID of the playlist. + /// @param details JsonDocument with the fields to update (name, public, collaborative, description). + /// @return response object containing http status code and reply. + response change_playlist_details(const char* playlist_id, JsonDocument details); + + /// @brief Create a new playlist for the current user. + /// @param name The name of the playlist. + /// @param description Description (Optional). + /// @param is_public Whether the playlist is public (default true). + /// @param collaborative Whether the playlist is collaborative (default false). + /// @return response object containing http status code and reply. + response create_playlist(const char* name, const char* description = nullptr, bool is_public = true, bool collaborative = false); + + /// @brief Get a list of the current user’s playlists. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_current_users_playlists(int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get full details of a playlist. + /// @param playlist_id The Spotify ID of the playlist. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_playlist(const char* playlist_id, JsonDocument filter = JsonDocument()); + + /// @brief Get the cover image(s) of a playlist. + /// @param playlist_id The Spotify ID of the playlist. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_playlist_cover_image(const char* playlist_id, JsonDocument filter = JsonDocument()); + + /// @brief Upload a custom cover image for a playlist. + /// @param playlist_id The Spotify ID of the playlist. + /// @param image_data Base64-encoded JPEG image data. + /// @return response object containing http status code and reply. + response upload_custom_playlist_cover_image(const char* playlist_id, const char* image_data); + + /// @brief Add one or more items to a playlist. + /// @param playlist_id The Spotify ID of the playlist. + /// @param size Number of URIs in the uris array. + /// @param uris Array of Spotify URIs (track or episode). + /// @param position The position to insert the items (0-based, -1 = end). + /// @return response object containing http status code and reply. + response add_items_to_playlist(const char* playlist_id, int size, const char** uris, int position = -1); + + /// @brief Remove one or more items from a playlist. + /// @param playlist_id The Spotify ID of the playlist. + /// @param size Number of URIs in the uris array. + /// @param uris Array of Spotify URIs to remove. + /// @param snapshot_id The playlist snapshot ID (Optional). + /// @return response object containing http status code and reply. + response remove_playlist_items(const char* playlist_id, int size, const char** uris, const char* snapshot_id = nullptr); + + /// @brief Reorder or replace items in a playlist. + /// @param playlist_id The Spotify ID of the playlist + /// @param size The size of the URI array + /// @param uris Array of Spotify URIs (for replace) or empty for reorder only. + /// @param range_start Start position of the range to move (for reorder). + /// @param insert_before Position to insert the range before. + /// @param range_length Length of the range to move (for reorder). + /// @param snapshot_id The playlist snapshot ID (Optional). + /// @return response object containing http status code and reply. + response update_playlist_items(const char* playlist_id, int size = 0, const char** uris = nullptr, int range_start = 0, int insert_before = 0, int range_length = 0, const char* snapshot_id = nullptr); + + /// @brief Get full details of the items of a playlist (new endpoint replacing old /tracks). + /// @param playlist_id The Spotify ID of the playlist. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_playlist_items(const char* playlist_id, int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + #endif + + #ifndef DISABLE_METADATA + /// @brief Get Spotify catalog information for a single album. + /// @param id The Spotify ID of the album. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_album(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information about an album’s tracks. + /// @param id The Spotify ID of the album. + /// @param limit The maximum number of tracks to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first track to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_album_tracks(const char* id, int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information for a single artist. + /// @param id The Spotify ID of the artist. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_artist(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information about an artist’s albums. + /// @param id The Spotify ID of the artist. + /// @param limit The maximum number of albums to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first album to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided).. + /// @return response object containing http status code and reply. + response get_artists_albums(const char* id, int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information for a single audiobook. + /// @param id The Spotify ID of the audiobook. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_audiobook(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information about an audiobook’s chapters. + /// @param id The Spotify ID of the audiobook. + /// @param limit The maximum number of chapters to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first chapter to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_audiobook_chapters(const char* id, int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information for a single audiobook chapter. + /// @param id The Spotify ID of the chapter. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_chapter(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information for a single episode. + /// @param id The Spotify ID of the episode. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_episode(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information for a single show. + /// @param id The Spotify ID of the show. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_show(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information about a show’s episodes. + /// @param id The Spotify ID of the show. + /// @param limit The maximum number of episodes to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first episode to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_show_episodes(const char* id, int limit = 10, int offset = 0, JsonDocument filter = JsonDocument()); + + /// @brief Get Spotify catalog information for a single track. + /// @param id The Spotify ID of the track. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_track(const char* id, JsonDocument filter = JsonDocument()); + + /// @brief Search for albums, artists, playlists, tracks, shows, episodes or audiobooks. + /// @param q The search query. + /// @param type Comma-separated list of item types (album,artist,playlist,track,show,episode,audiobook). + /// @param limit The maximum number of items to return. Default: 5. Minimum: 1. Maximum: 10. + /// @param offset The index of the first item to return. Default: 0. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response search_for_item(const char* q, const char* type, int limit = 5, int offset = 0, JsonDocument filter = JsonDocument()); + #endif + + #ifndef DISABLE_USER + /// @brief Get detailed profile information about the current user. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_current_users_profile(JsonDocument filter = JsonDocument()); + + /// @brief Get the current user’s top artists or tracks. + /// @param type The type of entity to return ("artists" or "tracks"). + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param offset The index of the first item to return. Default: 0. + /// @param time_range Over what time frame the affinities are computed ("short_term", "medium_term", "long_term"). + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_top_items(const char* type, int limit = 10, int offset = 0, const char* time_range = nullptr, JsonDocument filter = JsonDocument()); + + #endif + + #ifndef DISABLE_PLAYER + /// @brief Add an item to the end of the user’s playback queue. + /// @param uri Spotify URI of the item to add. + /// @param device_id Id of the device this command is targeting (Optional). + /// @return response object containing http status code and reply. + response add_item_to_queue(const char* uri, const char* device_id = nullptr); + + /// @brief Get information about a user’s available devices. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_available_devices(JsonDocument filter = JsonDocument()); + + /// @brief Get the object currently being played on the user’s account. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_currently_playing_track(JsonDocument filter = JsonDocument()); + + /// @brief Get information about the user’s current playback state. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_playback_state(JsonDocument filter = JsonDocument()); + + /// @brief Get tracks from the current user’s recently played tracks. + /// @param limit The maximum number of items to return. Default: 10. Minimum: 1. Maximum: 50. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_recently_played_tracks(int limit = 10, JsonDocument filter = JsonDocument()); + + /// @brief Get the list of items in the user’s playback queue. + /// @param filter JsonDocument containing the fields to filter (Optional, returns all fields if not provided). + /// @return response object containing http status code and reply. + response get_users_queue(JsonDocument filter = JsonDocument()); + + /// @brief Pause playback on the user’s account. + /// @return response object containing http status code and reply. + response pause_playback(); + + /// @brief Seek to the given position in the user’s currently playing track. + /// @param position_ms Position in milliseconds to seek to. + /// @return response object containing http status code and reply. + response seek_to_position(int position_ms); + + /// @brief Set the repeat mode for the user’s playback. + /// @param state Repeat mode ("off", "track", "context"). + /// @return response object containing http status code and reply. + response set_repeat_mode(const char* state); + + /// @brief Set the volume for the user’s playback. + /// @param volume_percent Volume value between 0 and 100. + /// @return response object containing http status code and reply. + response set_volume(int volume_percent); + + /// @brief Skip to the next track in the user’s queue. + /// @return response object containing http status code and reply. + response skip_to_next(); + + /// @brief Skip to the previous track in the user’s queue. + /// @return response object containing http status code and reply. + response skip_to_previous(); + + /// @brief Start a new playback or resume current playback. + /// @param device_id Id of the device this command is targeting (Optional). + /// @return response object containing http status code and reply. + response start_a_users_playback(const char* device_id = nullptr); + + /// @brief Toggle shuffle on or off for the user’s playback. + /// @param state Shuffle mode (true = on, false = off). + /// @return response object containing http status code and reply. + response toggle_shuffle(bool state); + + /// @brief Transfer playback to a new device and optionally begin playback. + /// @param device_id Id of the device this command is targeting. + /// @return response object containing http status code and reply. + response transfer_playback(const char* device_id); + #endif + + #ifndef DISABLE_SIMPLIFIED + /// @brief Returns the name of the currently playing track. + /// @return Track name as String. Empty if no track is active. + String current_track_name(); + + /// @brief Returns the Spotify ID of the currently playing track. + /// @return Track ID as String. Empty if unavailable. + String current_track_id(); + + /// @brief Returns the Spotify ID of the currently active device. + /// @return Device ID as String. Empty if no active device. + String current_device_id(); + + /// @brief Returns a comma-separated list of artist names + /// for the currently playing track. + /// @return Artist names as String. Empty if unavailable. + String current_artist_names(); + + /// @brief Writes the current device ID into a provided buffer. + /// @param device_id Pre-allocated buffer to store the device ID. + /// @return Pointer to the provided buffer. + char* current_device_id(char* device_id); + + /// @brief Writes the current track ID into a provided buffer. + /// @param track_id Pre-allocated buffer to store the track ID. + /// @return Pointer to the provided buffer. + char* current_track_id(char* track_id); + + /// @brief Writes the current track name into a provided buffer. + /// @param track_name Pre-allocated buffer to store the track name. + /// @return Pointer to the provided buffer. + char* current_track_name(char* track_name); + + /// @brief Writes the current artist names into a provided buffer. + /// @param artist_names Pre-allocated buffer to store + /// a comma-separated list of artist names. + /// @return Pointer to the provided buffer. + char* current_artist_names(char* artist_names); + + /// @brief Indicates whether playback is currently active. + /// @return True if the active device is playing. + bool is_playing(); + + /// @brief Indicates whether the current device supports volume control. + /// @return True if volume can be modified on the active device. + bool volume_modifyable(); + + /// @brief Returns the album cover image URL of the currently playing track. + /// @param image_size_idx Index of the image in the Spotify image array + /// (typically 0 = largest). + /// @return Image URL as String. Empty if unavailable. + String get_current_album_image_url(int image_size_idx); + #endif + + /// @brief Converts a Spotify ID to a Spotify URI. + /// @param id Null-terminated Spotify object ID. + /// @param type Null-terminated object type string. + /// @param uri Pre-allocated char buffer where the resulting URI will be written. + /// The buffer must be large enough to hold the full URI including the null terminator. + /// @return Pointer to the provided URI buffer. + char* convert_id_to_uri(const char* id, const char* type, char* uri); + + /// @brief Returns the stored user credential data. + /// @return A user_tokens struct containing client ID, client secret, and refresh token. + user_tokens get_user_tokens(); + + /// @brief Checks whether a valid access token is currently stored. + /// @return True if an access token is available. + bool has_access_token(); + + /// @brief Requests a new access token using the stored refresh token. + /// @return True if the token request was successful. + bool get_access_token(); + + /// @brief Exchanges an authorization code for a refresh token. + /// @param auth_code Null-terminated authorization code received from Spotify OAuth redirect. + /// @param redirect_uri Null-terminated redirect URI used during the authorization request. + /// Must match the URI configured in the Spotify Developer Dashboard. + /// @return True if the token exchange was successful. + bool get_refresh_token(const char* auth_code, const char* redirect_uri); + + /// @brief Sets the OAuth scopes for the authorization request. + /// @param scopes Null-terminated string containing space-separated scope names. + void set_scopes(const char* scopes); + + /// @brief Sets the internal logging level. + /// @param spotify_log_level Logging level enum value. + void set_log_level(spotify_log_level_t spotify_log_level); + + /// @brief Execute a Spotify API call asynchronously. + /// @param fn A lambda function wrapping the Spotify member function to call. Any required arguments must be bound inside the lambda. + /// @param callback Function pointer called once the async function completes. Receives the `response` object. + void async(std::function fn, AsyncCallback callback); + + /// @brief Stops internal services and releases allocated resources. + /// After calling this function, the object should not be used anymore unless reinitialized. + void end(); + + private: + /// @brief Logging tag used for debug output. + static constexpr const char* _TAG = "Spotify"; + + /// @brief OAuth redirect URI registered in the Spotify Developer Dashboard. + const char* _redirect_uri = "https://spotifyesp32.vercel.app/api/spotify/callback"; + + /// @brief OAuth authorization host. + const char* _auth_host = "spotifyesp32.vercel.app"; + + /// @brief Spotify Web API host. + const char* _host = "api.spotify.com"; + + /// @brief Spotify Accounts service host (token endpoint). + const char* _token_host = "accounts.spotify.com"; + + /// @brief Base URL for Spotify Web API endpoints. + const char* _base_url = "https://api.spotify.com/v1/"; + + /// @brief Maximum number of items allowed per API batch request. + static const int _max_num_items = 20; + + /// @brief Maximum size for URI aggregation buffers. + /// 35 = approx. URI length, + comma, + buffer for URL overhead. + static const int _max_char_size = 35 * _max_num_items + 150; + + /// @brief Default buffer size for standard URLs. + const int _standard_url_size = 100; + + /// @brief Maximum length of a Spotify URI including null terminator. + static const int _size_of_uri = 45; + + /// @brief Maximum length of a Spotify ID including null terminator. + static const int _size_of_id = 25; + + /// @brief OAuth state string length (without null terminator). + static const int _state_len = 25; + + /// @brief Random OAuth state used for CSRF protection. + char _random_state[_state_len + 1]; + + /// @brief True if credentials are provided at runtime instead of constructor. + bool _no_credentials = false; + + /// @brief Maximum number of automatic retries per request. + int _max_num_retry = 3; + + /// @brief Current retry counter. + int _retry; + + /// @brief Request timeout in milliseconds. + int _timeout = 5000; + + /// @brief User refresh token. + char _refresh_token[300] = ""; + + /// @brief Authorization code received during OAuth flow. + char _auth_code[1024] = ""; + + /// @brief Client ID provided by user. + char _client_id[100] = ""; + + /// @brief Client secret provided by user. + char _client_secret[100] = ""; + + /// @brief Current access token used for API requests. + char _access_token[400]; + + /// @brief Char to hold potential custom scopes. + const char* _custom_scopes; + + /// @brief Secure HTTPS client used for all API communication. + WiFiClientSecure _client; + + /// @brief Root certificate authority for Spotify endpoints (stored in flash). + const char* _spotify_root_ca PROGMEM = \ + "-----BEGIN CERTIFICATE-----\n"\ + "MIIEyDCCA7CgAwIBAgIQDPW9BitWAvR6uFAsI8zwZjANBgkqhkiG9w0BAQsFADBh\n"\ + "MQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3\n"\ + "d3cuZGlnaWNlcnQuY29tMSAwHgYDVQQDExdEaWdpQ2VydCBHbG9iYWwgUm9vdCBH\n"\ + "MjAeFw0yMTAzMzAwMDAwMDBaFw0zMTAzMjkyMzU5NTlaMFkxCzAJBgNVBAYTAlVT\n"\ + "MRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMxMzAxBgNVBAMTKkRpZ2lDZXJ0IEdsb2Jh\n"\ + "bCBHMiBUTFMgUlNBIFNIQTI1NiAyMDIwIENBMTCCASIwDQYJKoZIhvcNAQEBBQAD\n"\ + "ggEPADCCAQoCggEBAMz3EGJPprtjb+2QUlbFbSd7ehJWivH0+dbn4Y+9lavyYEEV\n"\ + "cNsSAPonCrVXOFt9slGTcZUOakGUWzUb+nv6u8W+JDD+Vu/E832X4xT1FE3LpxDy\n"\ + "FuqrIvAxIhFhaZAmunjZlx/jfWardUSVc8is/+9dCopZQ+GssjoP80j812s3wWPc\n"\ + "3kbW20X+fSP9kOhRBx5Ro1/tSUZUfyyIxfQTnJcVPAPooTncaQwywa8WV0yUR0J8\n"\ + "osicfebUTVSvQpmowQTCd5zWSOTOEeAqgJnwQ3DPP3Zr0UxJqyRewg2C/Uaoq2yT\n"\ + "zGJSQnWS+Jr6Xl6ysGHlHx+5fwmY6D36g39HaaECAwEAAaOCAYIwggF+MBIGA1Ud\n"\ + "EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFHSFgMBmx9833s+9KTeqAx2+7c0XMB8G\n"\ + "A1UdIwQYMBaAFE4iVCAYlebjbuYP+vq5Eu0GF485MA4GA1UdDwEB/wQEAwIBhjAd\n"\ + "BgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwdgYIKwYBBQUHAQEEajBoMCQG\n"\ + "CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wQAYIKwYBBQUHMAKG\n"\ + "NGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEdsb2JhbFJvb3RH\n"\ + "Mi5jcnQwQgYDVR0fBDswOTA3oDWgM4YxaHR0cDovL2NybDMuZGlnaWNlcnQuY29t\n"\ + "L0RpZ2lDZXJ0R2xvYmFsUm9vdEcyLmNybDA9BgNVHSAENjA0MAsGCWCGSAGG/WwC\n"\ + "ATAHBgVngQwBATAIBgZngQwBAgEwCAYGZ4EMAQICMAgGBmeBDAECAzANBgkqhkiG\n"\ + "9w0BAQsFAAOCAQEAkPFwyyiXaZd8dP3A+iZ7U6utzWX9upwGnIrXWkOH7U1MVl+t\n"\ + "wcW1BSAuWdH/SvWgKtiwla3JLko716f2b4gp/DA/JIS7w7d7kwcsr4drdjPtAFVS\n"\ + "slme5LnQ89/nD/7d+MS5EHKBCQRfz5eeLjJ1js+aWNJXMX43AYGyZm0pGrFmCW3R\n"\ + "bpD0ufovARTFXFZkAdl9h6g4U5+LXUZtXMYnhIHUfoyMo5tS58aI7Dd8KvvwVVo4\n"\ + "chDYABPPTHPbqjc1qCmBaZx2vN4Ye5DUys/vZwP9BFohFrH/6j/f3IL16/RZkiMN\n"\ + "JCqVJUzKoZHm1Lesh3Sz8W2jmdv51b2EQJ8HmA==\n"\ + "-----END CERTIFICATE-----\n"; + + /// @brief Generates a random OAuth state string. + /// @param state Buffer to store generated state. + /// @param len Length of buffer (including null terminator). + void get_random_state(char* state, size_t len); + + /// @brief URL-encodes a string. + /// @param src Null-terminated input string. + /// @param dest Destination buffer. + /// @param destSize Size of destination buffer. + void urlEncode(const char* src, char* dest, size_t destSize); + + /// @brief Extracts API endpoint path from a full REST URL. + /// @param rest_url Full REST URL. + /// @return Endpoint path as String. + String extract_endpoint(const char* rest_url); + + /// @brief Retrieves a new access token using the stored refresh token. + /// @return True if token retrieval was successful. + bool get_token(); + + /// @brief Sends a token request to the Spotify Accounts service. + /// @param payload URL-encoded request payload. + /// @param payload_len Length of payload in bytes. + /// @return True if HTTP request succeeded. + bool token_base_req(const char* payload, size_t payload_len); + + /// @brief Validates HTTP response code. + /// @param code HTTP status code. + /// @return True if status code indicates success. + bool valid_http_code(int code); + + /// @brief Processes HTTP response headers. + /// @return Parsed header response struct. + header_resp process_headers(); + + /// @brief Processes HTTP response body. + /// @param header Parsed header information. + /// @param filter Optional ArduinoJson filter document. + /// @return Parsed JsonDocument. + JsonDocument process_response(header_resp header, JsonDocument filter = JsonDocument()); + + /// @brief Initializes a response structure. + /// @param response_obj Pointer to response struct. + void init_response(response* response_obj); + + /// @brief Performs a generic REST API request. + /// @param rest_url Full API URL. + /// @param type HTTP method (GET, POST, PUT, DELETE). + /// @param payload_size Size of payload in bytes. + /// @param payload Optional request body. + /// @param filter Optional ArduinoJson filter. + /// @param content_type Content-Type header (default: application/json). + /// @return Response struct containing HTTP status and payload. + response RestApi(const char* rest_url, const char* type, int payload_size = 0, const char* payload = nullptr, JsonDocument filter = JsonDocument(), const char* content_type = "application/json"); + + /// @brief Performs a PUT request. + response RestApiPut(const char* rest_url, int payload_size = 0,const char* payload = nullptr, const char* content_type = "application/json"); + + /// @brief Performs a POST request. + response RestApiPost(const char* rest_url, int payload_size = 0, const char* payload = nullptr); + + /// @brief Performs a DELETE request. + response RestApiDelete(const char* rest_url, int payload_size = 0, const char* payload = nullptr); + + /// @brief Performs a GET request. + response RestApiGet(const char* rest_url, JsonDocument filter = JsonDocument()); +}; +#endif \ No newline at end of file