diff --git a/.gitignore b/.gitignore index 6675329..d40a532 100644 --- a/.gitignore +++ b/.gitignore @@ -367,3 +367,7 @@ vs-readme.txt .gcc-flags.json .pio/ .vscode/ + +# custom ignores +src/* +lib/secrets/* \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c7bce7e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,27 +0,0 @@ -language: python -python: - - "2.7" - -# Cache PlatformIO packages using Travis CI container-based infrastructure -sudo: false -cache: - directories: - - "~/.platformio" - -env: - - SCRIPT=platformioSingle EXAMPLE_NAME=ChannelStatistics EXAMPLE_FOLDER=/ BOARD=d1_mini - - SCRIPT=platformioSingle EXAMPLE_NAME=ChannelStatistics EXAMPLE_FOLDER=/ BOARD=tinypico - -install: - - pip install -U platformio - # - # Libraries from PlatformIO Library Registry: - # - # http://platformio.org/lib/show/64/ArduinoJson - - platformio lib -g install 64 - # http://platformio.org/lib/show/567/WifiManager - - platformio lib -g install 567 - # Double Reset Detector (not yet on PIO) - - platformio lib -g install https://github.com/datacute/DoubleResetDetector.git - -script: scripts/travis/$SCRIPT.sh diff --git a/README.md b/README.md index bfc2e2b..c3dd82f 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,14 @@ # Arduino YouTube API Library -[![arduino-library-badge](https://www.ardu-badge.com/badge/YoutubeApi.svg?)](https://www.ardu-badge.com/YoutubeApi) [![Build Status](https://travis-ci.org/witnessmenow/arduino-youtube-api.svg?branch=master)](https://travis-ci.org/witnessmenow/arduino-youtube-api) A wrapper for the [YouTube API](https://developers.google.com/youtube/v3/docs/) for Arduino. Works on both ESP8266 and ESP32. -![Imgur](http://i.imgur.com/FmXyW4E.png) +### Example fetching channel information: + +![Imgur](https://i.imgur.com/JaZR9m9.png) + +### Example fetching video information: + +![Imgur](https://i.imgur.com/hTbtVvg.png) ### Support what I do! @@ -42,16 +47,21 @@ then once you're connected to WiFi you can start requesting data from the API: #define CHANNEL_ID "UCezJOfu7OtqGzd5xrP3q6WA" - if(api.getChannelStatistics(CHANNEL_ID)) { + YoutubeChannel channel(CHANNEL_ID, &api); + + if(channel.getChannelStatistics()) { Serial.print("Subscriber Count: "); - Serial.println(api.channelStats.subscriberCount); + Serial.println(channel.channelStats->subscriberCount); } If you don't know it, you can find your own YouTube channel ID [here](https://www.youtube.com/account_advanced). See [the examples](examples) for more details on how to use the library. ## Supported Data Methods -Currently the only implemented method is getting channel statistics, but the library can be easily extended. Please raise an issue if there is a method you are looking for. +The library is currently able to fetch: + +- video list: snippet, status, statistics and contentDetails +- channel list: snippet, statistics and content details ## License diff --git a/examples/ChannelInformation/ChannelInformation.ino b/examples/ChannelInformation/ChannelInformation.ino new file mode 100644 index 0000000..99b6c1c --- /dev/null +++ b/examples/ChannelInformation/ChannelInformation.ino @@ -0,0 +1,279 @@ +/******************************************************************* + Read YouTube Channel information from the YouTube API + and print them to the serial monitor + + Compatible Boards: + * Any ESP8266 board + * Any ESP32 board + + Recommended Board: D1 Mini ESP8266 + http://s.click.aliexpress.com/e/uzFUnIe (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 of these will need to be installed +// ---------------------------- + +// Library for connecting to the YouTube API +// https://github.com/witnessmenow/arduino-youtube-api +// (search for "youtube" in the Arduino Library Manager) +#include "YoutubeApi.h" +#include "YoutubeChannel.h" +#include "YoutubePlaylist.h" +#include "YoutubeVideo.h" + +// Library used for parsing Json from the API responses +// https://github.com/bblanchon/ArduinoJson +// (search for "Arduino Json" in the Arduino Library Manager) +#include + +//------- Replace the following! ------ +const char ssid[] = "xxx"; // your network SSID (name) +const char password[] = "yyyy"; // your network key +#define API_KEY "zzzz" // your Google API key +#define CHANNEL_ID "UCezJOfu7OtqGzd5xrP3q6WA" // part of the channel url +//------- ---------------------- ------ + +#define channelIdLen 24 + +#define timeBetweenRequestGroup 120 * 1000 // 120 seconds, in milliseconds | time between all requests +#define timeBetweenRequests 2 * 1000 // 2 seconds, in milliseconds | time between single requests + +WiFiClientSecure client; +YoutubeApi api(API_KEY, client); + +char videoId[channelIdLen + 1]; +unsigned long startTime; + +/** + * @brief Tries to read the videoId from Serial. + * + * @return 1 on success, 0 if no data available + */ +int readVideoId(){ + + if(Serial.available() > channelIdLen - 1){ + + for(int i = 0; i < channelIdLen; i++){ + + videoId[i] = Serial.read(); + } + + videoId[channelIdLen] = '\0'; + return 1; + } + return 0; +} + +/** + * @brief Flushes the Serial input buffer. + * + */ +void flushSerialBuffer(){ + while(Serial.available()){ + Serial.read(); + } +} + +/** + * @brief Prints "Yes\n" if x or "No\n" if not x + * + * @param x parameter + */ +void printYesNo(bool x){ + if(x){ + Serial.println("Yes"); + }else{ + Serial.println("No"); + } +} + + +void setup() { + Serial.begin(115200); + + // Set WiFi to 'station' mode and disconnect + // from the AP if it was previously connected + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + // Connect to the WiFi network + Serial.print("\nConnecting to WiFi: "); + Serial.println(ssid); + WiFi.begin(ssid, password); + while (WiFi.status() != WL_CONNECTED) { + Serial.print("."); + delay(500); + } + Serial.println("\nWiFi connected!"); + Serial.print("IP address: "); + IPAddress ip = WiFi.localIP(); + Serial.println(ip); + + #ifdef ESP8266 + // Required if you are using ESP8266 V2.5 or above + client.setInsecure(); + #endif + + client.setInsecure(); + + // Uncomment for extra debugging info + // api._debug = true; + + flushSerialBuffer(); + Serial.print("Enter channelId: "); + + while(1){ + if(readVideoId()){ + flushSerialBuffer(); + break; + } + } + + Serial.println(videoId); +} + +void loop() { + + Serial.setTimeout(timeBetweenRequestGroup); + + YoutubeChannel channel(videoId, &api); + + // fetch and print information in channel.list:snippet + if(channel.getChannelSnippet()){ + Serial.println("\n\nsnippet"); + + channelSnippet *fetchedSnip = channel.channelSnip; + + + Serial.print("|----- Channel title: "); + Serial.println(fetchedSnip->title); + + Serial.print("|----- Channel description: "); + Serial.println(fetchedSnip->description); + + Serial.print("|----- Channel country: "); + Serial.println(fetchedSnip->country); + + tm channelCreation = fetchedSnip->publishedAt; + + Serial.print("|----- Channel creation (d.m.y h:m:s): "); + Serial.print(channelCreation.tm_mday); + Serial.print("."); + Serial.print(channelCreation.tm_mon); + Serial.print("."); + Serial.print(channelCreation.tm_year + 1900); + Serial.print(" "); + Serial.print(channelCreation.tm_hour); + Serial.print(":"); + Serial.print(channelCreation.tm_min); + Serial.print(":"); + Serial.println(channelCreation.tm_sec); + + Serial.println("-------------------------------------------------"); + } + + if(channel.getChannelStatistics()){ + Serial.println("\n\nstatistics"); + + channelStatistics *fetchedStats = channel.channelStats; + + Serial.print("|----- Channel views: "); + Serial.println(fetchedStats->viewCount); + + Serial.print("|----- Channel subscriber count hidden? "); + printYesNo(fetchedStats->hiddenSubscriberCount); + + Serial.print("|----- Channel subscribers: "); + Serial.println(fetchedStats->subscriberCount); + + Serial.print("|----- Channel video count: "); + Serial.println(fetchedStats->videoCount); + + Serial.println("-------------------------------------------------"); + } + + delay(timeBetweenRequests); + + // fetch and print information in channel.list:contentDetails + if(channel.getChannelContentDetails()){ + Serial.println("\n\ncontent details"); + + channelContentDetails *fetchedDetails = channel.channelContentDets; + + Serial.print("|----- Liked videos playlist id: "); + Serial.println(fetchedDetails->relatedPlaylistsLikes); + + Serial.print("|----- Uploaded videos playlist id: "); + Serial.println(fetchedDetails->relatedPlaylistsUploads); + + Serial.println("-------------------------------------------------"); + } + + if(channel.checkChannelContentDetailsSet() && channel.checkChannelSnipSet()){ + Serial.print("\n\nFetching last five videos of "); + Serial.println(channel.channelSnip->title); + + YoutubePlaylist recentVideos = YoutubePlaylist(&api, channel.channelContentDets->relatedPlaylistsUploads); + recentVideos.getPlaylistItemsPage(0); + + playlistItemsContentDetails *page = recentVideos.itemsContentDets; + + for(int i = 0; i < YT_PLAYLIST_ITEM_RESULTS_PER_PAGE; i++){ + char *videoId = page[i].videoId; + + if(!strcmp("", videoId)){ + break; + } + + YoutubeVideo vid = YoutubeVideo(videoId, &api); + if(vid.getVideoSnippet() && vid.getVideoStatistics()){ + Serial.print("videoId: "); + Serial.print(videoId); + Serial.print(" | \""); + Serial.print(vid.videoSnip->title); + Serial.print("\" | Views: "); + Serial.println(vid.videoStats->viewCount); + } + } + } + + + Serial.print("\nRefreshing in "); + Serial.print(timeBetweenRequestGroup / 1000.0); + Serial.println(" seconds..."); + Serial.print("Or set a new channelId: "); + + startTime = millis(); + flushSerialBuffer(); + + while(millis() - startTime < timeBetweenRequestGroup){ + + if(readVideoId()){; + Serial.println(videoId); + break; + } + } +} diff --git a/examples/ChannelStatistics/ChannelStatistics.ino b/examples/ChannelStatistics/ChannelStatistics.ino deleted file mode 100644 index ac1107c..0000000 --- a/examples/ChannelStatistics/ChannelStatistics.ino +++ /dev/null @@ -1,111 +0,0 @@ -/******************************************************************* - Read YouTube Channel statistics from the YouTube API - and print them to the serial monitor - - Compatible Boards: - * Any ESP8266 board - * Any ESP32 board - - Recommended Board: D1 Mini ESP8266 - http://s.click.aliexpress.com/e/uzFUnIe (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 of these will need to be installed -// ---------------------------- - -// Library for connecting to the YouTube API -// https://github.com/witnessmenow/arduino-youtube-api -// (search for "youtube" in the Arduino Library Manager) -#include - -// Library used for parsing Json from the API responses -// https://github.com/bblanchon/ArduinoJson -// (search for "Arduino Json" in the Arduino Library Manager) -#include - -//------- Replace the following! ------ -const char ssid[] = "xxx"; // your network SSID (name) -const char password[] = "yyyy"; // your network key -#define API_KEY "zzzz" // your Google API key -#define CHANNEL_ID "UCezJOfu7OtqGzd5xrP3q6WA" // part of the channel url -//------- ---------------------- ------ - -WiFiClientSecure client; -YoutubeApi api(API_KEY, client); - -unsigned long timeBetweenRequests = 60 * 1000; // 60 seconds, in milliseconds - -void setup() { - Serial.begin(115200); - - // Set WiFi to 'station' mode and disconnect - // from the AP if it was previously connected - WiFi.mode(WIFI_STA); - WiFi.disconnect(); - delay(100); - - // Connect to the WiFi network - Serial.print("\nConnecting to WiFi: "); - Serial.println(ssid); - WiFi.begin(ssid, password); - while (WiFi.status() != WL_CONNECTED) { - Serial.print("."); - delay(500); - } - Serial.println("\nWiFi connected!"); - Serial.print("IP address: "); - IPAddress ip = WiFi.localIP(); - Serial.println(ip); - - #ifdef ESP8266 - // Required if you are using ESP8266 V2.5 or above - client.setInsecure(); - #endif - - // Uncomment for extra debugging info - // api._debug = true; -} - -void loop() { - if(api.getChannelStatistics(CHANNEL_ID)) { - Serial.println("\n---------Stats---------"); - - Serial.print("Subscriber Count: "); - Serial.println(api.channelStats.subscriberCount); - - Serial.print("View Count: "); - Serial.println(api.channelStats.viewCount); - - Serial.print("Video Count: "); - Serial.println(api.channelStats.videoCount); - - // Probably not needed :) - //Serial.print("hiddenSubscriberCount: "); - //Serial.println(api.channelStats.hiddenSubscriberCount); - - Serial.println("------------------------"); - } - delay(timeBetweenRequests); -} diff --git a/examples/PlaylistInformation/PlaylistInformation.ino b/examples/PlaylistInformation/PlaylistInformation.ino new file mode 100644 index 0000000..4c7afb4 --- /dev/null +++ b/examples/PlaylistInformation/PlaylistInformation.ino @@ -0,0 +1,320 @@ +/******************************************************************* + Read YouTube Channel information from the YouTube API + and print them to the serial monitor + + Compatible Boards: + * Any ESP8266 board + * Any ESP32 board + + Recommended Board: D1 Mini ESP8266 + http://s.click.aliexpress.com/e/uzFUnIe (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 of these will need to be installed +// ---------------------------- + +// Library for connecting to the YouTube API +// https://github.com/witnessmenow/arduino-youtube-api +// (search for "youtube" in the Arduino Library Manager) +#include "YoutubeApi.h" +#include "YoutubePlaylist.h" +#include "YoutubeVideo.h" + +// Library used for parsing Json from the API responses +// https://github.com/bblanchon/ArduinoJson +// (search for "Arduino Json" in the Arduino Library Manager) +#include + +//------- Replace the following! ------ +const char ssid[] = "xxx"; // your network SSID (name) +const char password[] = "yyyy"; // your network key +#define API_KEY "zzzz" // your Google API key +//------------------------------------- + +#define playlistIdLen 24 + +#define timeBetweenRequestGroup 120 * 1000 // 120 seconds, in milliseconds | time between all requests +#define timeBetweenRequests 2 * 1000 // 2 seconds, in milliseconds | time between single requests + +WiFiClientSecure client; +YoutubeApi api(API_KEY, client); + +char playlistId[playlistIdLen + 1]; +unsigned long startTime; + +/** + * @brief Tries to read the playlistId from Serial. + * + * @return 1 on success, 0 if no data available + */ +int readPlaylist(){ + + if(Serial.available() > playlistIdLen - 1){ + + for(int i = 0; i < playlistIdLen; i++){ + + playlistId[i] = Serial.read(); + } + + playlistId[playlistIdLen] = '\0'; + return 1; + } + return 0; +} + +/** + * @brief Flushes the Serial input buffer. + * + */ +void flushSerialBuffer(){ + while(Serial.available()){ + Serial.read(); + } +} + +/** + * @brief Prints "Yes\n" if x or "No\n" if not x + * + * @param x parameter + */ +void printYesNo(bool x){ + if(x){ + Serial.println("Yes"); + }else{ + Serial.println("No"); + } +} + +void printTimestamp(tm t){ + Serial.print(t.tm_mday); + Serial.print("."); + Serial.print(t.tm_mon); + Serial.print("."); + Serial.print(t.tm_year + 1900); + Serial.print(" "); + Serial.print(t.tm_hour); + Serial.print(":"); + Serial.print(t.tm_min); + Serial.print(":"); + Serial.println(t.tm_sec); +} + +void printPlaylistItemsPage(YoutubePlaylist *plist){ + + playlistItemsConfiguration *config = plist->playlistItemsConfig; + + Serial.print("-------------------[Page "); + Serial.print(config->currentPage + 1); + Serial.print(" of "); + Serial.print((int) ceil(((float) config->totalResults) / YT_PLAYLIST_ITEM_RESULTS_PER_PAGE)); + Serial.print(" | Entries: "); + Serial.print(config->currentPageLastValidPos + 1); + Serial.println(" ]-------------------"); + + Serial.print("|----- previous Page token: "); + if(strcmp("", config->previousPageToken)){ + Serial.println(config->previousPageToken); + }else{ + Serial.println("[none]"); + } + + for(int entry = 0; entry < config->currentPageLastValidPos + 1; entry++){ + + YoutubeVideo vid(plist->itemsContentDets[entry].videoId, plist->getYoutubeApiObj()); + vid.getVideoSnippet(); + + Serial.print("\n|----- Entry: "); + Serial.println(entry + 1); + + Serial.print("|----- video id: "); + Serial.print(plist->itemsContentDets[entry].videoId); + + if(vid.checkVideoSnippetSet()){ + Serial.print("\t\t(\""); + Serial.print(vid.videoSnip->title); + Serial.println("\")"); + }else{ + Serial.print("\n"); + } + + Serial.print("|----- published at: "); + printTimestamp(plist->itemsContentDets[entry].videoPublishedAt); + } + + + Serial.print("\n|----- next Page token: "); + if(strcmp("", config->nextPageToken)){ + Serial.println(config->nextPageToken); + }else{ + Serial.println("[none]"); + } + + Serial.println("-------------------------------------------------"); +} + + +void setup() { + Serial.begin(115200); + + // Set WiFi to 'station' mode and disconnect + // from the AP if it was previously connected + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + // Connect to the WiFi network + Serial.print("\nConnecting to WiFi: "); + Serial.println(ssid); + WiFi.begin(ssid, password); + while (WiFi.status() != WL_CONNECTED) { + Serial.print("."); + delay(500); + } + Serial.println("\nWiFi connected!"); + Serial.print("IP address: "); + IPAddress ip = WiFi.localIP(); + Serial.println(ip); + + #ifdef ESP8266 + // Required if you are using ESP8266 V2.5 or above + client.setInsecure(); + #endif + + client.setInsecure(); + + // Uncomment for extra debugging info + // api._debug = true; + + flushSerialBuffer(); + Serial.print("Enter playlistId: "); + + while(1){ + if(readPlaylist()){ + flushSerialBuffer(); + break; + } + } + + Serial.println(playlistId); +} + +void loop() { + + Serial.setTimeout(timeBetweenRequestGroup); + + YoutubePlaylist playlist(&api, playlistId); + + // fetch and print information in channel.list:snippet + if(playlist.getPlaylistStatus()){ + Serial.println("\n\nstatus"); + + playlistStatus *status = playlist.status; + Serial.print("|----- privacy status: "); + Serial.println(status->privacyStatus); + + Serial.println("-------------------------------------------------"); + } + + + if(playlist.getPlaylistContentDetails()){ + Serial.println("\n\ncontentDetails"); + + playlistContentDetails *contentDetails = playlist.contentDets; + Serial.print("|----- item count: "); + Serial.println(contentDetails->itemCount); + + Serial.println("-------------------------------------------------"); + } + + + if(playlist.getPlaylistSnippet()){ + Serial.println("\n\nsnippet"); + + + playlistSnippet *snippet = playlist.snip; + + tm playlistCreation = snippet->publishedAt; + + Serial.print("|----- playlist creation (d.m.y h:m:s): "); + Serial.print(playlistCreation.tm_mday); + Serial.print("."); + Serial.print(playlistCreation.tm_mon); + Serial.print("."); + Serial.print(playlistCreation.tm_year + 1900); + Serial.print(" "); + Serial.print(playlistCreation.tm_hour); + Serial.print(":"); + Serial.print(playlistCreation.tm_min); + Serial.print(":"); + Serial.println(playlistCreation.tm_sec); + + Serial.print("|----- channel id: "); + Serial.println(snippet->channelId); + + Serial.print("|----- title: "); + Serial.println(snippet->title); + + Serial.print("|----- description: "); + Serial.println(snippet->description); + + Serial.print("|----- channel title: "); + Serial.println(snippet->channelTitle); + + Serial.print("|----- default language: "); + Serial.println(snippet->defaultLanguage); + + Serial.println("-------------------------------------------------"); + } + + Serial.println("\n"); + + int page = 0; + + do{ + + if(playlist.getPlaylistItemsPage(page)){ + printPlaylistItemsPage(&playlist); + } + + page++; + + }while(page < (int) ceil(((float) playlist.playlistItemsConfig->totalResults) / YT_PLAYLIST_ITEM_RESULTS_PER_PAGE)); + + Serial.print("\nRefreshing in "); + Serial.print(timeBetweenRequestGroup / 1000.0); + Serial.println(" seconds..."); + Serial.print("Or set a new playlistId: "); + + startTime = millis(); + flushSerialBuffer(); + + while(millis() - startTime < timeBetweenRequestGroup){ + + if(readPlaylist()){; + Serial.println(playlistId); + break; + } + } +} diff --git a/examples/VideoInformation/VideoInformation.ino b/examples/VideoInformation/VideoInformation.ino new file mode 100644 index 0000000..bad028b --- /dev/null +++ b/examples/VideoInformation/VideoInformation.ino @@ -0,0 +1,307 @@ +/******************************************************************* + Read all available YouTube Video information from the YouTube API + and print them to the serial monitor + + Compatible Boards: + * Any ESP8266 board + * Any ESP32 board + + Recommended Board: D1 Mini ESP8266 + http://s.click.aliexpress.com/e/uzFUnIe (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 and modified by Colum31 + 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 of these will need to be installed +// ---------------------------- + +// Library for connecting to the YouTube API +// https://github.com/witnessmenow/arduino-youtube-api +// (search for "youtube" in the Arduino Library Manager) +#include "YoutubeVideo.h" + +// Library used for parsing Json from the API responses +// https://github.com/bblanchon/ArduinoJson +// (search for "Arduino Json" in the Arduino Library Manager) +#include + +//------- Replace the following! ------ +#define WIFI_SSID "xxxx" // your network SSID (name) +#define WIFI_PASSWORD "yyyy" // your network key +#define API_KEY "zzzz" // your Google API key +//------- ---------------------- ------ + + + +#define timeBetweenRequestGroup 120 * 1000 // 120 seconds, in milliseconds | time between all requests +#define timeBetweenRequests 2 * 1000 // 2 seconds, in milliseconds | time between single requests +#define videoIdLen 11 + +WiFiClientSecure client; +YoutubeApi api(API_KEY, client); + +char videoId[videoIdLen + 1]; +unsigned long startTime; + +/** + * @brief Tries to read the videoId from Serial. + * + * @return 1 on success, 0 if no data available + */ +int readVideoId(){ + + if(Serial.available() > videoIdLen - 1){ + + for(int i = 0; i < videoIdLen; i++){ + + videoId[i] = Serial.read(); + } + + videoId[videoIdLen] = '\0'; + return 1; + } + return 0; +} + +/** + * @brief Flushes the Serial input buffer. + * + */ +void flushSerialBuffer(){ + while(Serial.available()){ + Serial.read(); + } +} + +/** + * @brief Prints "Yes\n" if x or "No\n" if not x + * + * @param x parameter + */ +void printYesNo(bool x){ + if(x){ + Serial.println("Yes"); + }else{ + Serial.println("No"); + } +} + + +void setup() { + Serial.begin(115200); + + // Set WiFi to 'station' mode and disconnect + // from the AP if it was previously connected + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + // Connect to the WiFi network + Serial.print("\nConnecting to WiFi: "); + Serial.println(WIFI_SSID); + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + while (WiFi.status() != WL_CONNECTED) { + Serial.print("."); + delay(500); + } + Serial.println("\nWiFi connected!"); + Serial.print("IP address: "); + IPAddress ip = WiFi.localIP(); + Serial.println(ip); + + #ifdef ESP8266 + // Required if you are using ESP8266 V2.5 or above + client.setInsecure(); + #endif + + client.setInsecure(); + + // Uncomment for extra debugging info + // api._debug = true; + + flushSerialBuffer(); + Serial.print("Enter videoId: "); + + while(1){ + if(readVideoId()){ + flushSerialBuffer(); + break; + } + } + + Serial.println(videoId); +} + +void loop() { + + YoutubeVideo vid(videoId, &api); + Serial.setTimeout(timeBetweenRequestGroup); + + + // fetch and print information in videos.list:statistics + if(vid.getVideoStatistics()){ + Serial.println("\n\nstatistics"); + + Serial.print("|----- Video views: "); + Serial.println(vid.videoStats->viewCount); + + Serial.print("|----- Likes: "); + Serial.println(vid.videoStats->likeCount); + + Serial.print("|----- Comments: "); + Serial.println(vid.videoStats->commentCount); + + Serial.println("-------------------------------------------------"); + } + + + + // fetch and print information in videos.list:snippet + if(vid.getVideoSnippet()){ + Serial.println("\n\nsnippet"); + + Serial.print("|----- Video title: "); + Serial.println(vid.videoSnip->title); + + Serial.println("|----- Video description: \n"); + Serial.println(vid.videoSnip->description); + Serial.println(""); + + Serial.print("|----- Uploaded by (channel title): "); + Serial.println(vid.videoSnip->channelTitle); + + Serial.print("|----- Uploaded by (channel id): "); + Serial.println(vid.videoSnip->channelId); + + Serial.print("|----- Published at (d.m.y h:m:s): "); + Serial.print(vid.videoSnip->publishedAt.tm_mday); + Serial.print("."); + Serial.print(vid.videoSnip->publishedAt.tm_mon); + Serial.print("."); + Serial.print(vid.videoSnip->publishedAt.tm_year + 1900); + Serial.print(" "); + Serial.print(vid.videoSnip->publishedAt.tm_hour); + Serial.print(":"); + Serial.print(vid.videoSnip->publishedAt.tm_min); + Serial.print(":"); + Serial.println(vid.videoSnip->publishedAt.tm_sec); + + Serial.print("|----- Livebroadcast content: "); + Serial.println(vid.videoSnip->liveBroadcastContent); + + Serial.print("|----- Category id: "); + Serial.println(vid.videoSnip->categoryId); + + Serial.print("|----- Default language: "); + Serial.println(vid.videoSnip->defaultLanguage); + + + Serial.print("|----- Default audio language: "); + Serial.println(vid.videoSnip->defaultAudioLanguage); + + Serial.println("-------------------------------------------------"); + } + + delay(timeBetweenRequests); + + // fetch and print information in videos.list:contentDetails + if(vid.getVideoContentDetails()){ + Serial.println("\n\ncontentDetails"); + + Serial.print("|----- Video duration "); + + if(vid.videoContentDets->duration.tm_mday != 0){ + Serial.print("(d:h:m:s): "); + Serial.print(vid.videoContentDets->duration.tm_mday); + Serial.print(":"); + }else{ + Serial.print("(h:m:s): "); + } + + + Serial.print(vid.videoContentDets->duration.tm_hour); + Serial.print(":"); + Serial.print(vid.videoContentDets->duration.tm_min); + Serial.print(":"); + Serial.println(vid.videoContentDets->duration.tm_sec); + + Serial.print("|----- Dimension: "); + Serial.println(vid.videoContentDets->dimension); + + Serial.print("|----- Definition: "); + Serial.println(vid.videoContentDets->defintion); + + Serial.print("|----- Captioned: "); + printYesNo(vid.videoContentDets->caption); + + Serial.print("|----- Licensed Content: "); + printYesNo(vid.videoContentDets->licensedContent); + + Serial.print("|----- Projection: "); + Serial.println(vid.videoContentDets->projection); + + Serial.println("-------------------------------------------------"); + } + + delay(timeBetweenRequests); + + if(vid.getVideoStatus()){ + Serial.println("\n\n status"); + + Serial.print("|----- upload status: "); + Serial.println(vid.vStatus->uploadStatus); + + Serial.print("|----- privacy status: "); + Serial.println(vid.vStatus->privacyStatus); + + Serial.print("|----- license: "); + Serial.println(vid.vStatus->license); + + Serial.print("|----- embeddable: "); + printYesNo(vid.vStatus->embeddable); + + Serial.print("|----- public stats viewable: "); + printYesNo(vid.vStatus->publicStatsViewable); + + Serial.print("|----- made for kids: "); + printYesNo(vid.vStatus->madeForKids); + } + + + + Serial.print("\nRefreshing in "); + Serial.print(timeBetweenRequestGroup / 1000.0); + Serial.println(" seconds..."); + Serial.print("Or set a new videoId: "); + + startTime = millis(); + flushSerialBuffer(); + + while(millis() - startTime < timeBetweenRequestGroup){ + + if(readVideoId()){; + Serial.println(videoId); + break; + } + } +} diff --git a/keywords.txt b/keywords.txt index f8a30fe..fc00275 100644 --- a/keywords.txt +++ b/keywords.txt @@ -10,7 +10,13 @@ YoutubeApi KEYWORD1 # API Data -channelStatistics KEYWORD1 +channelStatistics KEYWORD1 +videoStatistics KEYWORD1 +videoContentDetails KEYWORD1 +videoStatistics KEYWORD1 +videoStatus KEYWORD1 +videoSnippet KEYWORD1 +operation KEYWORD1 ####################################### # Methods and Functions (KEYWORD2) @@ -19,6 +25,10 @@ channelStatistics KEYWORD1 # API Member Functions sendGetToYoutube KEYWORD2 getChannelStatistics KEYWORD2 +getVideoStatistics KEYWORD2 +getContentDetails KEYWORD2 +getSnippet KEYWORD2 +getVideoStatus KEYWORD2 ####################################### # Instances (KEYWORD2) @@ -32,3 +42,6 @@ getChannelStatistics KEYWORD2 YTAPI_HOST LITERAL1 YTAPI_SSL_PORT LITERAL1 YTAPI_TIMEOUT LITERAL1 +YTAPI_CHANNEL_ENDPOINT LITERAL1 +YTAPI_VIDEO_ENDPOINT LITERAL1 +YTAPI_REQUEST_FORMAT LITERAL1 \ No newline at end of file diff --git a/lib/YoutubeApi/YoutubeApi.cpp b/lib/YoutubeApi/YoutubeApi.cpp new file mode 100644 index 0000000..0d8c9ba --- /dev/null +++ b/lib/YoutubeApi/YoutubeApi.cpp @@ -0,0 +1,367 @@ +/* + * YoutubeApi - An Arduino wrapper for the YouTube API + * Copyright (c) 2020 Brian Lough + * + * MIT License + * + * 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. + * + */ + +// TODO +// +// add video.list:topicDetails +// add custom error types + +#include "YoutubeApi.h" + +char YoutubeApi::apiKey[YTAPI_KEY_LEN + 1] = ""; + +YoutubeApi::YoutubeApi(const char* key, Client &client) : client(client) +{ + strncpy(apiKey, key, YTAPI_KEY_LEN); + apiKey[YTAPI_KEY_LEN] = '\0'; +} + +YoutubeApi::YoutubeApi(const String &key, Client &newClient) + : YoutubeApi(key.c_str(), newClient) // passing the key as c-string to force a copy +{} + + +int YoutubeApi::sendGetToYoutube(const char *command) { + client.flush(); + client.setTimeout(YTAPI_TIMEOUT); + if (!client.connect(YTAPI_HOST, YTAPI_SSL_PORT)) + { + Serial.println(F("Connection failed")); + return false; + } + // give the esp a breather + yield(); + + // Send HTTP request + client.print(F("GET ")); + client.print(command); + client.println(F(" HTTP/1.1")); + + //Headers + client.print(F("Host: ")); + client.println(YTAPI_HOST); + + client.println(F("Cache-Control: no-cache")); + + if (client.println() == 0) + { + Serial.println(F("Failed to send request")); + return -1; + } + + int statusCode = getHttpStatusCode(); + + // Let the caller of this method parse the JSon from the client + skipHeaders(); + return statusCode; +} + + +int YoutubeApi::sendGetToYoutube(const String& command) { + return sendGetToYoutube(command.c_str()); +} + + +/** + * @brief Creates a url-string to request data from. + * + * @param mode The type of request to make a string for. (operation enum) + * @param command The destination of the string. + * @param id The id of the resource. + * @return true on success, false on error + */ +bool YoutubeApi::createRequestString(int mode, char* command, const char *id) { + + switch (mode) + { + case videoListStats: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_VIDEO_ENDPOINT, YTAPI_PART_STATISTICS, id, apiKey); + break; + + case videoListContentDetails: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_VIDEO_ENDPOINT, YTAPI_PART_CONTENTDETAILS, id, apiKey); + break; + + case videoListSnippet: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_VIDEO_ENDPOINT, YTAPI_PART_SNIPPET, id, apiKey); + break; + + case videoListStatus: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_VIDEO_ENDPOINT, YTAPI_PART_STATUS, id, apiKey); + break; + + case channelListStats: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_CHANNEL_ENDPOINT, YTAPI_PART_STATISTICS, id, apiKey); + break; + + case channelListSnippet: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_CHANNEL_ENDPOINT, YTAPI_PART_SNIPPET, id, apiKey); + break; + + case channelListContentDetails: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_CHANNEL_ENDPOINT, YTAPI_PART_CONTENTDETAILS, id, apiKey); + break; + + case playlistListStatus: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_PLAYLIST_ENDPOINT, YTAPI_PART_STATUS, id, apiKey); + break; + + case playlistListContentDetails: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_PLAYLIST_ENDPOINT, YTAPI_PART_CONTENTDETAILS, id, apiKey); + break; + + case playlistListSnippet: + sprintf(command, YTAPI_REQUEST_FORMAT, YTAPI_PLAYLIST_ENDPOINT, YTAPI_PART_SNIPPET, id, apiKey); + break; + + case playlistItemsListContentDetails: + sprintf(command, YTAPI_PLAYLIST_ITEMS_REQUEST_FORMAT, YTAPI_PLAYLIST_ITEMS_ENDPOINT, YTAPI_PART_CONTENTDETAILS, id, apiKey); + break; + + default: + Serial.println("Unknown operation"); + return false; + } + + return true; +} + + +/** + * @brief Parses the ISO8601 duration string into a tm time struct. + * + * @param duration Pointer to string + * @return tm time struct corresponding to duration. When sucessful, it's non zero. + */ +tm YoutubeApi::parseDuration(const char *duration){ + // FIXME + // rewrite this with strtok? + tm ret; + memset(&ret, 0, sizeof(tm)); + + if(!duration){ + return ret; + } + + char temp[3]; + + int len = strlen(duration); + int marker = len - 1; + + bool secondsSet, minutesSet, hoursSet, daysSet; + secondsSet = minutesSet = hoursSet = daysSet = false; + + for(int i = len - 1; i >= 0; i--){ + + char c = duration[i]; + + if(c == 'S'){ + secondsSet = true; + marker = i - 1; + continue; + } + + if(c == 'M'){ + minutesSet = true; + + if(secondsSet){ + memcpy(&temp, &duration[i + 1], marker - i + 1); + ret.tm_sec = atoi(temp); + + secondsSet = false; + } + marker = i - 1; + continue; + } + + if(c == 'H'){ + hoursSet = true; + + if(secondsSet || minutesSet){ + + memcpy(&temp, &duration[i + 1], marker - i + 1); + int time = atoi(temp); + + if(secondsSet){ + ret.tm_sec = time; + secondsSet = false; + }else{ + ret.tm_min = time; + minutesSet = false; + } + } + + marker = i - 1; + continue; + } + + if(c == 'T'){ + + if(secondsSet || minutesSet || hoursSet){ + + memcpy(&temp, &duration[i + 1], marker - i + 1); + int time = atoi(temp); + + if(secondsSet){ + ret.tm_sec = time; + }else if (minutesSet){ + ret.tm_min = time; + }else{ + ret.tm_hour = time; + } + } + } + // a video can be as long as days + if(c == 'D'){ + marker = i - 1; + daysSet = true; + } + + if(c == 'P' && daysSet){ + + memcpy(&temp, &duration[i + 1], marker - i + 1); + int time = atoi(temp); + + ret.tm_mday = time; + + } + } + return ret; +} + + +/** + * @brief Parses the ISO8601 date time string into a tm time struct. + * + * @param dateTime Pointer to string + * @return tm time struct corresponding to specified time. When sucessful, it's non zero. + */ +tm YoutubeApi::parseUploadDate(const char* dateTime){ + + tm ret; + memset(&ret, 0, sizeof(tm)); + + if(!dateTime){ + return ret; + } + + int checksum = sscanf(dateTime, "%4d-%2d-%2dT%2d:%2d:%2dZ", &ret.tm_year, &ret.tm_mon, &ret.tm_mday, + &ret.tm_hour, &ret.tm_min, &ret.tm_sec); + + if(checksum != 6){ + printf("sscanf didn't scan in correctly\n"); + memset(&ret, 0, sizeof(tm)); + return ret; + } + + ret.tm_year -= 1900; + + return ret; +} + + + +/** + * @brief Allocates memory and copies a string into it. + * + * @param pos where to store a pointer of the allocated memory to + * @param data pointer of data to copy + * @return int 0 on success, 1 on error + */ +int YoutubeApi::allocAndCopy(char **pos, const char *data){ + + if(!data){ + Serial.println("data is a NULL pointer!"); + return 1; + } + + if(!pos){ + Serial.println("pos is a NULL pointer!"); + return 1; + } + + size_t sizeOfData = strlen(data) + 1; + char *allocatedMemory = (char*) malloc(sizeOfData); + + if(!allocatedMemory){ + Serial.println("malloc returned NULL pointer!"); + return 1; + } + + *pos = allocatedMemory; + + memcpy(allocatedMemory, data, sizeOfData); + allocatedMemory[sizeOfData - 1] = '\0'; + + return 0; +} + + +void YoutubeApi::skipHeaders() { + // Skip HTTP headers + char endOfHeaders[] = "\r\n\r\n"; + if (!client.find(endOfHeaders)) + { + Serial.println(F("Invalid response")); + return; + } + + // 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); + } +} + + +int YoutubeApi::getHttpStatusCode() { + // Check HTTP status + if(client.find("HTTP/1.1")){ + int statusCode = client.parseInt(); + return statusCode; + } + + return -1; +} + +bool YoutubeApi::checkEmptyResponse(DynamicJsonDocument response){ + + if(response["pageInfo"]["totalResults"].as() == 0){ + return true; + } + + return false; +} + + +void YoutubeApi::closeClient() { + if(client.connected()) { + client.flush(); + client.stop(); + } +} diff --git a/src/YoutubeApi.h b/lib/YoutubeApi/YoutubeApi.h similarity index 72% rename from src/YoutubeApi.h rename to lib/YoutubeApi/YoutubeApi.h index 28169ad..d411968 100644 --- a/src/YoutubeApi.h +++ b/lib/YoutubeApi/YoutubeApi.h @@ -28,41 +28,37 @@ #define YoutubeApi_h #include +#include "YoutubeTypes.h" #include +#include #include -#define YTAPI_HOST "www.googleapis.com" -#define YTAPI_SSL_PORT 443 -#define YTAPI_TIMEOUT 1500 - -#define YTAPI_CHANNEL_ENDPOINT "/youtube/v3/channels" - -struct channelStatistics { - long viewCount; - long commentCount; /* DEPRECATED */ - long subscriberCount; - bool hiddenSubscriberCount; - long videoCount; -}; - class YoutubeApi { public: - YoutubeApi(const char *key, Client &client); - YoutubeApi(const String& apiKey, Client& client); + YoutubeApi(const char *key, Client &newClient); + YoutubeApi(const String& key, Client& newClient); + + static bool createRequestString(int mode, char *command, const char *id); + int sendGetToYoutube(const char *command); int sendGetToYoutube(const String& command); - bool getChannelStatistics(const char *channelId); - bool getChannelStatistics(const String& channelId); - channelStatistics channelStats; + + static int allocAndCopy(char **pos, const char *data); + static tm parseUploadDate(const char *dateTime); + static tm parseDuration(const char *duration); + static bool checkEmptyResponse(DynamicJsonDocument response); + bool _debug = false; + Client &client; + + void closeClient(); private: - const String apiKey; - Client &client; + static char apiKey[YTAPI_KEY_LEN + 1]; int getHttpStatusCode(); + void skipHeaders(); - void closeClient(); }; #endif diff --git a/lib/YoutubeChannel/YoutubeChannel.cpp b/lib/YoutubeChannel/YoutubeChannel.cpp new file mode 100644 index 0000000..287b490 --- /dev/null +++ b/lib/YoutubeChannel/YoutubeChannel.cpp @@ -0,0 +1,361 @@ +#include "YoutubeChannel.h" + +YoutubeChannel::YoutubeChannel(const char *newChannelId, YoutubeApi *newApiObj){ + if(newApiObj){ + apiObj = newApiObj; + } + + setChannelId(newChannelId); +} + +/** + * @brief Private function to set the channel id. + * + * @param newChannelId new channel id o set + */ +void YoutubeChannel::setChannelId(const char *newChannelId){ + + // TODO: When an invalid channel id is being rejected, it should block certain actions + // like fetching data etc. + if(!newChannelId || strlen(newChannelId) != YT_CHANNELID_LEN || newChannelId[0] != 'U' || newChannelId[1] != 'C'){ + return; + } + + strncpy(channelId, newChannelId, YT_CHANNELID_LEN); + channelId[YT_CHANNELID_LEN] = '\0'; + channelIdSet = true; +} + +/** + * @brief Returns the stored channel id. + * + * @return const char* currently stored channel id. If none is stored, returns an empty string + */ +const char* YoutubeChannel::getChannelId(){ + return channelId; +} + +/** + * @brief Returns the current YoutubeApi object used to fetch requests. + * + * @return YoutubeApi* pointer to currrent YoutubeApi object + */ +YoutubeApi* YoutubeChannel::getYoututbeApiObj(){ + return apiObj; +} + +/** + * @brief Deletes channel statistics and resets the flag. + * + */ +void YoutubeChannel::freeChannelStats(){ + if(channelStatsSet && channelStats){ + free(channelStats); + channelStats = NULL; + channelStatsSet = false; + } +} + +/** + * @brief Frees channelContentDetails struct of object. + * + */ +void YoutubeChannel::freeChannelContentDetails(){ + if(!channelContentDetailsSet){ + return; + } + + free(channelContentDets->relatedPlaylistsLikes); + free(channelContentDets->relatedPlaylistsUploads); + free(channelContentDets); + + channelContentDets = NULL; + channelContentDetailsSet = false; +} + + +/** + * @brief Returns the flag indicating if channel id is currently set (and valid). + * + * @return boolean value of the flag + */ +bool YoutubeChannel::checkChannelIdSet(){ return channelIdSet; } + +/** + * @brief Returns the flag indicating if channel statistics object is currently set (and valid). + * + * @return boolean value of the flag + */ +bool YoutubeChannel::checkChannelStatsSet(){return channelStatsSet;} + +/** + * @brief Returns the flag indicating if channel snippet object is currently set (and valid). + * + * @return boolean value of the flag + */ +bool YoutubeChannel::checkChannelSnipSet(){return channelSnipSet;}; + + +/** + * @brief Returns the flag indicating if channel content details object is currently set (and valid). + * + * @return boolean value of the flag + */ +bool YoutubeChannel::checkChannelContentDetailsSet(){return channelContentDetailsSet;} + +/** + * @brief Resets all information of the YoutubeChannel object, except the YoutubeApi object. + * + */ +void YoutubeChannel::resetInfo(){ + freeChannelStats(); + freeChannelSnippet(); + freeChannelContentDetails(); + + strncpy(channelId, "", YT_CHANNELID_LEN + 1); + channelIdSet = false; +} + +YoutubeChannel::~YoutubeChannel(){ + resetInfo(); +} + +/** + * @brief Fetches channel statistics of the set channel id. + * + * @return true on success, otherwise false + */ +bool YoutubeChannel::getChannelStatistics(){ + if(channelStatsSet){ + freeChannelStats(); + } + + char command[150]; + YoutubeApi::createRequestString(channelListStats, command, channelId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseChannelStatistics(); + } + + return false; +} + + +/** + * @brief Parses the channel statistics from caller client. Stores information in calling object. + * + * @return true on success, false on error + */ +bool YoutubeChannel::parseChannelStatistics() { + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = JSON_ARRAY_SIZE(1) + + JSON_OBJECT_SIZE(2) + + 2*JSON_OBJECT_SIZE(4) + + JSON_OBJECT_SIZE(5) + + 330; + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find channel id!"); + apiObj->closeClient(); + return wasSuccessful; + } + + channelStatistics *newChannelStats = (channelStatistics*) malloc(sizeof(channelStatistics)); + + JsonObject itemStatistics = doc["items"][0]["statistics"]; + + newChannelStats->viewCount = itemStatistics["viewCount"].as(); + newChannelStats->subscriberCount = itemStatistics["subscriberCount"].as(); + newChannelStats->hiddenSubscriberCount = itemStatistics["hiddenSubscriberCount"].as(); + newChannelStats->videoCount = itemStatistics["videoCount"].as(); + + channelStats = newChannelStats; + channelStatsSet = true; + + wasSuccessful = true; + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} + + + +/** + * @brief Fetches channel statistics of the set channel id. + * + * @return true on success, otherwise false + */ +bool YoutubeChannel::getChannelSnippet(){ + if(channelSnipSet){ + freeChannelSnippet(); + } + + char command[150]; + YoutubeApi::createRequestString(channelListSnippet, command, channelId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseChannelSnippet(); + } + + return false; +} + + +/** + * @brief Parses the channel statistics from caller client. Stores information in calling object. + * + * @return true on success, false on error + */ +bool YoutubeChannel::parseChannelSnippet() { + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 2000; + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find channel id!"); + apiObj->closeClient(); + return wasSuccessful; + } + + channelSnippet *newChannelSnippet = (channelSnippet*) malloc(sizeof(channelSnippet)); + + JsonObject itemSnippet = doc["items"][0]["snippet"]; + + uint8_t errorCode = 0; + + errorCode += YoutubeApi::allocAndCopy(&newChannelSnippet->title, itemSnippet["title"].as()); + errorCode += YoutubeApi::allocAndCopy(&newChannelSnippet->description, itemSnippet["description"].as()); + errorCode += YoutubeApi::allocAndCopy(&newChannelSnippet->country, itemSnippet["country"].as()); + + newChannelSnippet->publishedAt = YoutubeApi::parseUploadDate(itemSnippet["publishedAt"].as()); + + if(errorCode){ + Serial.print("Error code: "); + Serial.print(errorCode); + }else{ + channelSnip = newChannelSnippet; + channelSnipSet = true; + + wasSuccessful = true; + } + + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} + + +/** + * @brief Frees the channel snippet struct of the object. + * + */ +void YoutubeChannel::freeChannelSnippet(){ + + if(!channelSnip){ + channelSnipSet = false; + return; + } + + free(channelSnip->title); + free(channelSnip->description); + free(channelSnip->country); + + free(channelSnip); + + channelSnip = NULL; + channelSnipSet = false; +} + +/** + * @brief Fetches channel contentDetails of the set channel id. + * + * @return true on success, false on error + */ +bool YoutubeChannel::getChannelContentDetails(){ + if(channelContentDetailsSet){ + freeChannelContentDetails(); + } + + char command[150]; + YoutubeApi::createRequestString(channelListContentDetails, command, channelId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseChannelContentDetails(); + } + + return false; +} + +bool YoutubeChannel::parseChannelContentDetails(){ + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 600; + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find channel id!"); + apiObj->closeClient(); + return wasSuccessful; + } + + channelContentDetails *newChannelContentDetails = (channelContentDetails*) malloc(sizeof(channelContentDetails)); + + JsonObject itemContentDetails = doc["items"][0]["contentDetails"]["relatedPlaylists"]; + + uint8_t errorCode = 0; + + errorCode += YoutubeApi::allocAndCopy(&newChannelContentDetails->relatedPlaylistsLikes, itemContentDetails["likes"].as()); + errorCode += YoutubeApi::allocAndCopy(&newChannelContentDetails->relatedPlaylistsUploads, itemContentDetails["uploads"].as()); + + if(errorCode){ + Serial.print("Error code: "); + Serial.print(errorCode); + }else{ + channelContentDets = newChannelContentDetails; + channelContentDetailsSet = true; + + wasSuccessful = true; + } + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} diff --git a/lib/YoutubeChannel/YoutubeChannel.h b/lib/YoutubeChannel/YoutubeChannel.h new file mode 100644 index 0000000..ac95bb1 --- /dev/null +++ b/lib/YoutubeChannel/YoutubeChannel.h @@ -0,0 +1,50 @@ +#include "YoutubeApi.h" +#include "YoutubeTypes.h" + + +class YoutubeChannel{ + + public: + YoutubeChannel(YoutubeApi *newApiObj): YoutubeChannel(NULL, newApiObj){}; + YoutubeChannel(const char *newChannelId, YoutubeApi *newApiObj); + YoutubeChannel(String& newChannelId, YoutubeApi *newApiObj): YoutubeChannel(newChannelId.c_str(), newApiObj) {}; + + ~YoutubeChannel(); + + channelStatistics *channelStats = NULL; + channelSnippet *channelSnip = NULL; + channelContentDetails *channelContentDets = NULL; + + bool checkChannelStatsSet(); + bool checkChannelSnipSet(); + bool checkChannelIdSet(); + bool checkChannelContentDetailsSet(); + + YoutubeApi* getYoututbeApiObj(); + const char *getChannelId(); + void resetInfo(); + + bool getChannelStatistics(); + bool getChannelSnippet(); + bool getChannelContentDetails(); + + private: + + char channelId[YT_CHANNELID_LEN + 1] = ""; + YoutubeApi *apiObj = NULL; + + bool channelIdSet = false; + bool channelStatsSet = false; + bool channelSnipSet = false; + bool channelContentDetailsSet = false; + + void freeChannelStats(); + void freeChannelSnippet(); + void freeChannelContentDetails(); + + void setChannelId(const char *newChannelId); + + bool parseChannelStatistics(); + bool parseChannelSnippet(); + bool parseChannelContentDetails(); +}; \ No newline at end of file diff --git a/lib/YoutubePlaylist/YoutubePlaylist.cpp b/lib/YoutubePlaylist/YoutubePlaylist.cpp new file mode 100644 index 0000000..310d68c --- /dev/null +++ b/lib/YoutubePlaylist/YoutubePlaylist.cpp @@ -0,0 +1,558 @@ +#include "YoutubePlaylist.h" + + +YoutubePlaylist::YoutubePlaylist(YoutubeApi *newApiObj, const char *newPlaylistId){ + strncpy(playlistId, newPlaylistId, YT_PLAYLISTID_LEN); + playlistId[YT_PLAYLISTID_LEN] = '\0'; + + apiObj = newApiObj; +} + + +YoutubePlaylist::~YoutubePlaylist(){ + + freePlaylistSnippet(); + freePlaylistContentDetails(); + freePlaylistStatus(); + freePlaylistItemsConfig(); +} + + +/** + * @brief Frees playlistSnippet struct of object and resets flag and pointer. + * + */ +void YoutubePlaylist::freePlaylistSnippet(){ + + if(!snipSet){ + return; + } + + free(snip->channelId); + free(snip->title); + free(snip->description); + free(snip->channelTitle); + + free(snip); + snipSet = false; + snip = NULL; +} + + +/** + * @brief Frees playlistContentDetails struct of object and resets flag and pointer. + * + */ +void YoutubePlaylist::freePlaylistContentDetails(){ + + if(!contentDetsSet){ + return; + } + + free(contentDets); + contentDetsSet = false; + contentDets = NULL; +} + + +/** + * @brief Frees playlistStatus struct of object and resets flag and pointer. + * + */ +void YoutubePlaylist::freePlaylistStatus(){ + + if(!statusSet){ + return; + } + + free(status->privacyStatus); + + free(status); + statusSet = false; + status = NULL; +} + + +/** + * @brief Frees playlistItemsConfiguration struct of object and resets flag and pointer. + * + */ +void YoutubePlaylist::freePlaylistItemsConfig(){ + + if(!itemsConfigSet){ + return; + } + + free(playlistItemsConfig); + itemsConfigSet = false; + playlistItemsConfig = NULL; +} + + +/** + * @brief Returns the value of the playlistStatusSet flag, indicating a valid object. + * + * @return Value of flag. + */ +bool YoutubePlaylist::checkPlaylistStatusSet(){return statusSet;} + + +/** + * @brief Returns the value of the playlistSnipSet flag, indicating a valid object. + * + * @return Value of flag. + */ +bool YoutubePlaylist::checkPlaylistSnipSet(){return snipSet;} + + +/** + * @brief Returns the value of the itemsConfigSet flag, indicating a valid object. + * + * @return Value of flag. + */ +bool YoutubePlaylist::checkPlaylistContentDetsSet(){return contentDetsSet;} + + +/** + * @brief Returns the value of the itemsConfigSet flag, indicating a valid connfiguration. + * + * @return Value of flag. + */ +bool YoutubePlaylist::checkItemsConfigSet(){return itemsConfigSet;} + + +/** + * @brief Returns the value of the itemsContentDetsSet flag, indicating a valid object. + * + * @return Value of flag. + */ +bool YoutubePlaylist::checkItemsContentDetsSet(){return itemsContentDetsSet;} + + +/** + * @brief Returns the currently set playlist id. + * + * @return const char* playlistId + */ +const char* YoutubePlaylist::getPlaylistId(){return playlistId;} + + +/** + * @brief Returns the YoutubeApi object of the object. + * + * @return pointer to YoutubeApi object + */ +YoutubeApi* YoutubePlaylist::getYoutubeApiObj(){return apiObj;} + + +/** + * @brief Fetches playlist status of the set playlist id. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::getPlaylistStatus(){ + + freePlaylistStatus(); + + char command[150]; + YoutubeApi::createRequestString(playlistListStatus, command, playlistId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parsePlaylistStatus(); + } + + return false; +} + +/** + * @brief Parses the response of the api request to retrieve the playlistStatus. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::parsePlaylistStatus(){ + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 512; + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find playlistId!"); + apiObj->closeClient(); + return wasSuccessful; + } + + playlistStatus *newplaylistStatus = (playlistStatus*) malloc(sizeof(playlistStatus)); + + uint8_t errorCode = 0; + + errorCode += YoutubeApi::allocAndCopy(&newplaylistStatus->privacyStatus, doc["items"][0]["status"]["privacyStatus"].as()); + + if(errorCode){ + Serial.print("Error code: "); + Serial.print(errorCode); + }else{ + status = newplaylistStatus; + statusSet = true; + + wasSuccessful = true; + } + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} + + +/** + * @brief Fetches playlist content details of the set playlist id. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::getPlaylistContentDetails(){ + + freePlaylistContentDetails(); + + char command[150]; + YoutubeApi::createRequestString(playlistListContentDetails, command, playlistId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parsePlaylistContentDetails(); + } + + return false; +} + +/** + * @brief Parses the response of the api request to retrieve the playlist content details. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::parsePlaylistContentDetails(){ + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 512; // recommended 384, but it throwed errors + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find playlistId!"); + apiObj->closeClient(); + return wasSuccessful; + } + + playlistContentDetails *newPlaylistContentDetails = (playlistContentDetails*) malloc(sizeof(playlistContentDetails)); + + newPlaylistContentDetails->itemCount = doc["items"][0]["contentDetails"]["itemCount"].as(); + + contentDets = newPlaylistContentDetails; + contentDetsSet = true; + wasSuccessful = true; + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} + + +/** + * @brief Fetches playlist snippet of the set playlist id. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::getPlaylistSnippet(){ + + freePlaylistSnippet(); + + char command[150]; + YoutubeApi::createRequestString(playlistListSnippet, command, playlistId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parsePlaylistSnippet(); + } + + return false; +} + + +/** + * @brief Parses the response of the api request to retrieve the playlist content details. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::parsePlaylistSnippet(){ + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 1600; // is just enough for upload playlists. It might not work for user made playlists. + // 1600 Bytes is way too large. #TODO: implement filtering to reduce allocated space + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find playlistId!"); + apiObj->closeClient(); + return wasSuccessful; + } + + playlistSnippet *newPlaylistSnippet = (playlistSnippet*) malloc(sizeof(playlistSnippet)); + + uint8_t errorCode = 0; + + errorCode += YoutubeApi::allocAndCopy(&newPlaylistSnippet->channelId, doc["items"][0]["snippet"]["channelId"].as()); + errorCode += YoutubeApi::allocAndCopy(&newPlaylistSnippet->title, doc["items"][0]["snippet"]["title"].as()); + errorCode += YoutubeApi::allocAndCopy(&newPlaylistSnippet->description, doc["items"][0]["snippet"]["description"].as()); + + errorCode += YoutubeApi::allocAndCopy(&newPlaylistSnippet->channelTitle, doc["items"][0]["snippet"]["channelTitle"].as()); + + char *ret = strncpy(newPlaylistSnippet->defaultLanguage, doc["items"][0]["snippet"]["defaultLanguage"].as(), 3); + newPlaylistSnippet->defaultLanguage[3] = '\0'; + + newPlaylistSnippet->publishedAt = YoutubeApi::parseUploadDate(doc["items"][0]["snippet"]["publishedAt"].as()); + + snip = newPlaylistSnippet; + snipSet = true; + wasSuccessful = true; + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} + + +/** + * @brief Creates and initiliazes an playlistItems configuration object + * + */ +void YoutubePlaylist::setConfig(){ + playlistItemsConfiguration *newConfig = (playlistItemsConfiguration*) malloc(sizeof(playlistItemsConfiguration)); + playlistItemsConfig = newConfig; + playlistItemsConfig->currentPage = 0; + itemsConfigSet = true; +} + +/** + * @brief Gets a page of playlistItems. A page token can be passed. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::getPlaylistItemsContentDetails(bool usePageToken, char *pageToken){ + + char command[180]; + char tokenAndPlaylistId[50]; + + if(usePageToken){ + sprintf(tokenAndPlaylistId, "%s&pageToken=%s", playlistId, pageToken); + }else{ + strcpy(tokenAndPlaylistId, playlistId); + } + + YoutubeApi::createRequestString(playlistItemsListContentDetails, command, tokenAndPlaylistId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parsePlaylistItemsContentDetails(); + + } + + return false; +} + + +/** + * @brief Parses a page of playlistItems:contentDetails. It also modifies values of playlistItemsConfig. + * + * @return true on success, false on error + */ +bool YoutubePlaylist::parsePlaylistItemsContentDetails(){ + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 2048; + DynamicJsonDocument doc(bufferSize); + +/* + Can not get the filter to work - for now. + It appears the optional parameters (nextPageToken and prevPageToken) break the filter. + + StaticJsonDocument<48> filter; + + filter["nextPageToken"] = true; + filter["prevPageToken"] = true; + filter["items"][0]["contentDetails"] = true; + filter["pageInfo"] = true; +*/ + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client); + if (!error){ + + if(YoutubeApi::checkEmptyResponse(doc)){ + Serial.println("Could not find playlistId!"); + apiObj->closeClient(); + return wasSuccessful; + } + + uint8_t pos = 0; + + for (JsonObject item : doc["items"].as()) { + + strcpy(itemsContentDets[pos].videoId ,item["contentDetails"]["videoId"]); + itemsContentDets[pos].videoPublishedAt = YoutubeApi::parseUploadDate(item["contentDetails"]["videoPublishedAt"]); + + pos++; + } + + playlistItemsConfig->currentPageLastValidPos = pos - 1; + + // if page not full, fill in with dummy data + if(pos != YT_PLAYLIST_ITEM_RESULTS_PER_PAGE - 1){ + for(int i = pos; i < YT_PLAYLIST_ITEM_RESULTS_PER_PAGE; i++){ + strcpy(itemsContentDets[i].videoId ,""); + itemsContentDets[i].videoPublishedAt = YoutubeApi::parseUploadDate("1970-01-01T00:00:00Z"); + } + } + + if(doc["nextPageToken"].as()){ + strcpy(playlistItemsConfig->nextPageToken, doc["nextPageToken"].as()); + }else{ + strcpy(playlistItemsConfig->nextPageToken, ""); + } + + if(doc["prevPageToken"].as()){ + strcpy(playlistItemsConfig->previousPageToken, doc["prevPageToken"].as()); + }else{ + strcpy(playlistItemsConfig->previousPageToken, ""); + } + + playlistItemsConfig->totalResults = doc["pageInfo"]["totalResults"]; + + if(doc["pageInfo"]["resultsPerPage"] != YT_PLAYLIST_ITEM_RESULTS_PER_PAGE){ + Serial.println("WARNING: Unexpected resultsPerPage!"); + } + + itemsContentDetsSet = true; + wasSuccessful = true; + } + else{ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + + apiObj->closeClient(); + return wasSuccessful; +} + + +bool YoutubePlaylist::getPlaylistItemsPage(int pageNum){ + + if(pageNum < 0){ + Serial.println("Page number must be greater than zero!"); + return false; + } + + if(!playlistItemsConfig || !itemsContentDetsSet){ + // if it is the first time, the object fetches thee page, get the first page first + setConfig(); + bool ret = getPlaylistItemsContentDetails(false, NULL); + + if(!ret){return ret;} + } + + //check if page exists + if((int) ceil(((float) playlistItemsConfig->totalResults) / YT_PLAYLIST_ITEM_RESULTS_PER_PAGE) < pageNum){ + Serial.println("Page number too large!"); + return false; + } + + int diff = pageNum - playlistItemsConfig->currentPage; + + // TODO: add skiping logic => sometimes it is faster to skip to the start and traversed from there + // TODO: when traversing playlist, contentDetails dont need to be parsed or fetched + + while(diff != 0){ + bool ret; + + if(diff > 0){ + ret = getNextPlaylistItemsPage(); + diff--; + }else{ + ret = getPreviousPlaylistItemsPage(); + diff++; + } + + if(!ret){ + Serial.println("Error traversing!"); + return ret; + } + } + return true; +} + + +bool YoutubePlaylist::getPreviousPlaylistItemsPage(){ + + if(strcmp("", playlistItemsConfig->previousPageToken) == 0){ + Serial.print("There is no previous page!"); + return false; + } + + char prevPageToken[YT_PLALIST_ITEMS_PAGE_TOKEN_LEN + 1]; + strcpy(prevPageToken, playlistItemsConfig->previousPageToken); + + bool ret = getPlaylistItemsContentDetails(true, prevPageToken); + if(!ret){return ret;} + + strcpy(playlistItemsConfig->currentPageToken, prevPageToken); + playlistItemsConfig->currentPage--; + + return true; +} + + +bool YoutubePlaylist::getNextPlaylistItemsPage(){ + + if(strcmp("", playlistItemsConfig->nextPageToken) == 0){ + Serial.print("There is no next page!"); + return false; + } + + char nextPageToken[YT_PLALIST_ITEMS_PAGE_TOKEN_LEN + 1]; + strcpy(nextPageToken, playlistItemsConfig->nextPageToken); + + bool ret = getPlaylistItemsContentDetails(true, nextPageToken); + if(!ret){return ret;} + + strcpy(playlistItemsConfig->currentPageToken, nextPageToken); + playlistItemsConfig->currentPage++; + + return true; +} diff --git a/lib/YoutubePlaylist/YoutubePlaylist.h b/lib/YoutubePlaylist/YoutubePlaylist.h new file mode 100644 index 0000000..46d42d6 --- /dev/null +++ b/lib/YoutubePlaylist/YoutubePlaylist.h @@ -0,0 +1,67 @@ +#include "YoutubeApi.h" +#include "YoutubeTypes.h" + + +class YoutubePlaylist{ + + public: + YoutubePlaylist(YoutubeApi *newApiObj, const char *newPlaylistId); + YoutubePlaylist(YoutubeApi *newApiObj, String& newPlaylistId): YoutubePlaylist(newApiObj, newPlaylistId.c_str()) {}; + + ~YoutubePlaylist(); + + bool checkPlaylistStatusSet(); + bool checkPlaylistSnipSet(); + bool checkPlaylistContentDetsSet(); + + bool checkItemsConfigSet(); + bool checkItemsContentDetsSet(); + + const char* getPlaylistId(); + + bool getPlaylistStatus(); + bool getPlaylistContentDetails(); + bool getPlaylistSnippet(); + + bool getPlaylistItemsPage(int pageNum); + + + YoutubeApi* getYoutubeApiObj(); + + playlistSnippet *snip = NULL; + playlistContentDetails *contentDets = NULL; + playlistStatus *status = NULL; + + // "caches" a page of playlistItems + playlistItemsContentDetails itemsContentDets[YT_PLAYLIST_ITEM_RESULTS_PER_PAGE]; + + playlistItemsConfiguration *playlistItemsConfig = NULL; + private: + + char playlistId[YT_PLAYLISTID_LEN + 1]; + YoutubeApi *apiObj = NULL; + + bool snipSet = false; + bool contentDetsSet = false; + bool statusSet = false; + bool itemsConfigSet = false; + bool itemsContentDetsSet = false; + + void freePlaylistSnippet(); + void freePlaylistContentDetails(); + void freePlaylistStatus(); + void freePlaylistItemsConfig(); + + bool parsePlaylistStatus(); + bool parsePlaylistContentDetails(); + bool parsePlaylistSnippet(); + + void setConfig(); + + bool getPlaylistItemsContentDetails(bool usePageToken, char *pageToken); + + bool getNextPlaylistItemsPage(); + bool getPreviousPlaylistItemsPage(); + + bool parsePlaylistItemsContentDetails(); +}; diff --git a/lib/YoutubeTypes/YoutubeTypes.h b/lib/YoutubeTypes/YoutubeTypes.h new file mode 100644 index 0000000..bee8381 --- /dev/null +++ b/lib/YoutubeTypes/YoutubeTypes.h @@ -0,0 +1,168 @@ +#ifndef YT_TYPES +#define YT_TYPES + +#include +#include + +#define YT_VIDEOID_LEN 11 +#define YT_CHANNELID_LEN 24 +#define YT_PLAYLISTID_LEN 24 +#define YT_PLALIST_ITEMS_PAGE_TOKEN_LEN 14 +#define YT_PLAYLIST_ITEM_RESULTS_PER_PAGE 5 + +#define YTAPI_HOST "www.googleapis.com" +#define YTAPI_SSL_PORT 443 +#define YTAPI_TIMEOUT 1500 + +#define YTAPI_CHANNEL_ENDPOINT "/youtube/v3/channels" +#define YTAPI_VIDEO_ENDPOINT "/youtube/v3/videos" +#define YTAPI_PLAYLIST_ENDPOINT "/youtube/v3/playlists" +#define YTAPI_PLAYLIST_ITEMS_ENDPOINT "/youtube/v3/playlistItems" + +#define YTAPI_REQUEST_FORMAT "%s?part=%s&id=%s&key=%s" +#define YTAPI_PLAYLIST_ITEMS_REQUEST_FORMAT "%s?part=%s&playlistId=%s&key=%s" + +#define YTAPI_PART_STATISTICS "statistics" +#define YTAPI_PART_CONTENTDETAILS "contentDetails" +#define YTAPI_PART_SNIPPET "snippet" +#define YTAPI_PART_STATUS "status" + +#define YTAPI_KEY_LEN 45 + +enum operation{ + + videoListStats, + videoListContentDetails, + videoListSnippet, + videoListStatus, + + channelListStats, + channelListSnippet, + channelListContentDetails, + + playlistListStatus, + playlistListContentDetails, + playlistListSnippet, + + playlistItemsListContentDetails +}; + + +// not implemented data fields are commented out + +struct playlistItemsConfiguration{ + uint16_t totalResults; +// uint8_t resultsPerPage; should be YT_PLAYLIST_ITEM_RESULTS_PER_PAGE + + uint16_t currentPage; + uint8_t currentPageLastValidPos; // last valid data entry on page + char currentPageToken[YT_PLALIST_ITEMS_PAGE_TOKEN_LEN + 1] = ""; + + char nextPageToken[YT_PLALIST_ITEMS_PAGE_TOKEN_LEN + 1] = ""; + char previousPageToken[YT_PLALIST_ITEMS_PAGE_TOKEN_LEN + 1] = ""; +}; + +struct playlistItemsContentDetails{ + char videoId[YT_VIDEOID_LEN + 1] = ""; + tm videoPublishedAt; +}; + +struct playlistContentDetails{ + uint32_t itemCount; +}; + +struct playlistSnippet{ + tm publishedAt; + char *channelId; + char *title; + char *description; +// char **thumbnails; + char *channelTitle; + char defaultLanguage[4]; +// char **localized; + +}; + +struct playlistStatus{ + char *privacyStatus; +}; + + +struct channelStatistics { + uint64_t viewCount; +// long commentCount; /* DEPRECATED */ + uint64_t subscriberCount; + bool hiddenSubscriberCount; + uint32_t videoCount; +}; + +struct channelSnippet { + char *title; + char *description; + // char *customUrl; + tm publishedAt; + //char **thumbnails; + char *country; +}; + +struct channelContentDetails{ + + char* relatedPlaylistsLikes; + char* relatedPlaylistsUploads; +}; + +struct videoContentDetails{ + tm duration; + char dimension[3]; + char defintion[3]; + bool caption; + bool licensedContent; +// char **regionRestriction; +// char **contentRating; + char projection[12]; +// bool hasCustomThumbnail; +}; + + +struct videoStatistics { + uint64_t viewCount; // uint64_t required for popular videos. (Baby Shark would else overflow xD) + uint32_t commentCount; + uint32_t likeCount; +// long favourites; +// long dislikeCount; + +// In Memory of the old dislike count. +}; + + +struct videoStatus{ + bool set; + char *uploadStatus; +// char *failureReason; +// char *rejectionReason; + char *privacyStatus; +// tm publishAt; + char *license; + bool embeddable; + bool publicStatsViewable; + bool madeForKids; +// bool selfDeclaredMadeForKids; +}; + +struct videoSnippet{ + bool set; + tm publishedAt; + char *channelId; + char *title; + char *description; +// char **thumbnails; + char *channelTitle; +// char **tags; + int categoryId; + char *liveBroadcastContent; + char *defaultLanguage; +// char **localized; + char *defaultAudioLanguage; +}; + +#endif diff --git a/lib/YoutubeVideo/YoutubeVideo.cpp b/lib/YoutubeVideo/YoutubeVideo.cpp new file mode 100644 index 0000000..6e41371 --- /dev/null +++ b/lib/YoutubeVideo/YoutubeVideo.cpp @@ -0,0 +1,575 @@ +#include "YoutubeVideo.h" + +YoutubeVideo::YoutubeVideo(const char *newVideoId, YoutubeApi *obj){ + + if(newVideoId == NULL){ + return; + } + apiObj = obj; + setVideoId(newVideoId); +} + +YoutubeVideo::YoutubeVideo(): apiObj(){} + +/** + * @brief Sets the new videoId. Sets videoIdSet on success + * + * @param newVideoId new videoId to set + * @return true If the id is valid (len = YT_VIDEOID_LEN) + * @return false If the id is not valid or a null pointer. + */ +bool YoutubeVideo::setVideoId(const char *newVideoId){ + + if(!newVideoId || strlen(newVideoId) != YT_VIDEOID_LEN){ + return false; + } + + strncpy(videoId, newVideoId, YT_VIDEOID_LEN); + videoId[YT_VIDEOID_LEN] = '\0'; + videoIdSet = true; + + return true; +} + +/** + * @brief Deletes all information from object, except the YoutubeApi object. + * + */ +void YoutubeVideo::resetInfo(){ + + if(videoSnipSet){ + freeVideoSnippet(videoSnip); + videoSnip = NULL; + videoSnipSet = false; + } + + if(vStatusSet){ + freeVideoStatus(vStatus); + vStatus = NULL; + vStatusSet = false; + } + + if(videoStatsSet){ + free(videoStats); + videoStats = NULL; + videoStatsSet = false; + } + + if(videoContentDetsSet){ + free(videoContentDets); + videoContentDets = NULL; + videoContentDetsSet = false; + } + + strncpy(videoId, "", YT_VIDEOID_LEN + 1); + videoIdSet = false; +} + + +YoutubeVideo::~YoutubeVideo(){ + resetInfo(); +} + + +/** + * @brief Frees memory used by strings in videoStatus struct and the struct itself. + * + * @param s Pointer to videoStatus struct to free + * + * @return true on success, false on error + */ +bool YoutubeVideo::freeVideoStatus(videoStatus *s){ + + if(!s){ + return false; + } + + if(!s->set){ + return false; + } + + free(s->uploadStatus); + free(s->license); + free(s->privacyStatus); + + free(s); + + return true; +} + +/** + * @brief Getter function to check if videoId is set. + * + * @return true if it is set, false otherwise + */ +bool YoutubeVideo::checkVideoIdSet(){ + return videoIdSet; +} + +/** + * @brief Getter function to check if videoSnip is set. + * + * @return true if it is set, false otherwise + */ +bool YoutubeVideo::checkVideoSnippetSet() {return videoSnipSet;}; + +/** + * @brief Getter function to check if videoStats is set. + * + * @return true if it is set, false otherwise + */ +bool YoutubeVideo::checkVideoStatisticsSet(){return videoStatsSet;}; + +/** + * @brief Getter function to check if videoContentDets is set. + * + * @return true if it is set, false otherwise + */ +bool YoutubeVideo::checkVideoContentDetailsSet(){return videoContentDetsSet;}; + +/** + * @brief Getter function to check if vStatus is set. + * + * @return true if it is set, false otherwise + */ +bool YoutubeVideo::checkVideoStatusSet(){return vStatusSet;}; + +/** + * @brief Getter function to get the value of the current videoId. + * + * @return returns a pointer to the videoId. Might be NULL. + */ +const char* YoutubeVideo::getVideoId(){ + return videoId; +} + +YoutubeApi* YoutubeVideo::getYoutubeApiObj(){ + return apiObj; +} + +/** + * @brief Getter function to get the value of the current videoId as String. + * + * @return returns a pointer to the videoId. Might be NULL. + */ +String YoutubeVideo::getVideoIdString(){ + return String(videoId); +} + +/** + * @brief Resets the videoId and all information in the object. + * Even if the id is not valid, all information gets deleted. + * + * @param newVideoId new video id to set + * @return bool true on success, false if setting the id fails + */ +bool YoutubeVideo::resetVideoId(const char *newVideoId){ + + resetInfo(); + + if(newVideoId == NULL){ + return false; + } + return setVideoId(newVideoId); +} + +bool YoutubeVideo::resetVideoId(String& newVideoId){ + return resetVideoId(newVideoId.c_str()); +} + + +/** + * @brief Frees memory used by strings in videoSnippet struct and the struct itself. + * + * @param s Pointer to videoSnippet struct to free + * @return true on success, false on error + */ +bool YoutubeVideo::freeVideoSnippet(videoSnippet *s){ + + if(!s){ + return false; + } + + if(!s->set){ + return false; + } + + free(s->channelId); + free(s->title); + free(s->description); + free(s->channelTitle); + free(s->liveBroadcastContent); + free(s->defaultLanguage); + free(s->defaultAudioLanguage); + + free(s); + + return true; +} + + +/** + * @brief Parses the video statistics from client in YoutubeApi object. Stores information in calling object. + * + * @return true on success, false on error + */ +bool YoutubeVideo::parseVideoStatistics(){ + + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = JSON_ARRAY_SIZE(1) + + JSON_OBJECT_SIZE(2) + + 2*JSON_OBJECT_SIZE(4) + + JSON_OBJECT_SIZE(5) + + 330; + + DynamicJsonDocument doc(bufferSize); + DeserializationError error = deserializeJson(doc, apiObj->client); + + if (error){ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + else if(doc["pageInfo"]["totalResults"].as() == 0){ + Serial.println("No results found for video id "); + } + else{ + videoStatistics *newStats = (videoStatistics*) malloc(sizeof(videoStatistics)); + + newStats->viewCount = doc["items"][0]["statistics"]["viewCount"].as(); + newStats->likeCount = doc["items"][0]["statistics"]["likeCount"].as(); + newStats->commentCount= doc["items"][0]["statistics"]["commentCount"].as(); + + videoStats = newStats; + videoStatsSet = true; + wasSuccessful = true; + } + + apiObj->closeClient(); + + return wasSuccessful; +} + +bool YoutubeVideo::getVideoStatistics(){ + + if(checkVideoStatisticsSet()){ + free(videoStats); + videoStatsSet = false; + videoStats = NULL; + } + + char command[150]; + YoutubeApi::createRequestString(videoListStats, command, videoId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseVideoStatistics(); + } + + return false; +} + +/** + * @brief Gets the snippet of a specific video. Stores them in the calling object. + * + * @param videoId videoID of the video to get the information from + * @return wasSuccesssful true, if there were no errors and the video was found + */ +bool YoutubeVideo::getVideoSnippet(){ + + if(checkVideoSnippetSet()){ + if(!freeVideoSnippet(videoSnip)){ + return false; + } + + videoSnipSet = false; + videoSnip = NULL; + } + + char command[150]; + YoutubeApi::createRequestString(videoListSnippet, command, videoId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseVideoSnippet(); + } + + return false; +} + +/** + * @brief Parses the video snippet from caller client. Stores information in calling object. + * + * @return true on success, false on error + */ +bool YoutubeVideo::parseVideoSnippet(){ + + bool wasSuccessful = false; + + // should be more, just to test + // description can be as large as 5kb, title 400 bytes + const size_t bufferSize = 6000; + + // Creating a filter to filter out + // metadata, thumbnail links, tags, localized information + StaticJsonDocument<256> filter; + + JsonObject filterItems = filter["items"][0].createNestedObject("snippet"); + filterItems["publishedAt"] = true; + filterItems["channelId"] = true; + filterItems["channelTitle"] = true; + filterItems["title"] = true; + filterItems["description"] = true; + filterItems["categoryId"] = true; + filterItems["liveBroadcastContent"] = true; + filterItems["defaultLanguage"] = true; + filterItems["defaultAudioLanguage"] = true; + filter["pageInfo"] = true; + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client, DeserializationOption::Filter(filter)); + + // check for errors and empty response + if(error){ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + else if(doc["pageInfo"]["totalResults"].as() == 0){ + Serial.println("No results found for video id "); + } + else{ + videoSnippet *newSnippet = (videoSnippet*) malloc(sizeof(videoSnippet)); + + int checksum = 0; + + newSnippet->publishedAt = YoutubeApi::parseUploadDate(doc["items"][0]["snippet"]["publishedAt"]); + newSnippet->categoryId = doc["items"][0]["snippet"]["categoryId"].as(); + + checksum += YoutubeApi::allocAndCopy(&newSnippet->channelId, doc["items"][0]["snippet"]["channelId"].as()); + checksum += YoutubeApi::allocAndCopy(&newSnippet->title, doc["items"][0]["snippet"]["title"].as()); + checksum += YoutubeApi::allocAndCopy(&newSnippet->description, doc["items"][0]["snippet"]["description"].as()); + checksum += YoutubeApi::allocAndCopy(&newSnippet->channelTitle, doc["items"][0]["snippet"]["channelTitle"].as()); + checksum += YoutubeApi::allocAndCopy(&newSnippet->liveBroadcastContent, doc["items"][0]["snippet"]["liveBroadcastContent"].as()); + + // language informations appears to be optional, so it is being checked if it is in response + // if not, a placeholder will be set + if(!doc["items"][0]["snippet"]["defaultLanguage"].as()){ + checksum += YoutubeApi::allocAndCopy(&newSnippet->defaultLanguage, ""); + }else{ + checksum += YoutubeApi::allocAndCopy(&newSnippet->defaultLanguage, doc["items"][0]["snippet"]["defaultLanguage"].as()); + } + + if(!doc["items"][0]["snippet"]["defaultAudioLanguage"].as()){ + checksum += YoutubeApi::allocAndCopy(&newSnippet->defaultAudioLanguage, ""); + }else{ + checksum += YoutubeApi::allocAndCopy(&newSnippet->defaultAudioLanguage, doc["items"][0]["snippet"]["defaultAudioLanguage"].as()); + } + + if(checksum){ + // don't set snip.set flag in order to avoid false free + Serial.print("Error reading in response values. Checksum: "); + Serial.println(checksum); + videoSnipSet = false; + wasSuccessful = false; + }else{ + videoSnipSet = true; + videoSnip = newSnippet; + wasSuccessful = true; + } + } + + apiObj->closeClient(); + delay(20); + return wasSuccessful; +} + +/** + * @brief Gets the status of a specific video. Stores them in the calling object. + * + * @param videoId videoID of the video to get the information from + * @return wasSuccesssful true, if there were no errors and the video was found + */ +bool YoutubeVideo::getVideoStatus(){ + if(checkVideoStatusSet()){ + if(!freeVideoStatus(vStatus)){ + return false; + } + vStatusSet = false; + vStatus = NULL; + } + + char command[150]; + YoutubeApi::createRequestString(videoListStatus, command, videoId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseVideoStatus(); + } + return false; +} + + +/** + * @brief Parses the video status from caller client. Stores information in calling object. + * + * @return true on success, false on error + */ +bool YoutubeVideo::parseVideoStatus(){ + + bool wasSuccessful = false; + const size_t bufferSize = 384; + + // Creating a filter to filter out + // metadata, thumbnail links, tags, localized information + + StaticJsonDocument<192> filter; + + JsonObject filterItems = filter["items"][0].createNestedObject("status"); + filterItems["uploadStatus"] = true; + filterItems["privacyStatus"] = true; + filterItems["license"] = true; + filterItems["embeddable"] = true; + filterItems["publicStatsViewable"] = true; + filterItems["madeForKids"] = true; + + JsonObject filterPageInfo = filter.createNestedObject("pageInfo"); + filterPageInfo["totalResults"] = true; + filterPageInfo["resultsPerPage"] = true; + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client, DeserializationOption::Filter(filter)); + + // check for errors and empty response + if(error){ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + else if(doc["pageInfo"]["totalResults"].as() == 0){ + Serial.println("No results found for video id "); + } + else{ + JsonObject itemsStatus = doc["items"][0]["status"]; + + videoStatus *newVideoStatus = (videoStatus*) malloc(sizeof(videoStatus)); + + int checksum = 0; + checksum += YoutubeApi::allocAndCopy(&newVideoStatus->uploadStatus, itemsStatus["uploadStatus"]); + checksum += YoutubeApi::allocAndCopy(&newVideoStatus->privacyStatus, itemsStatus["privacyStatus"]); + checksum += YoutubeApi::allocAndCopy(&newVideoStatus->license, itemsStatus["license"]); + + newVideoStatus->embeddable = itemsStatus["embeddable"]; // true + newVideoStatus->publicStatsViewable = itemsStatus["publicStatsViewable"]; // true + newVideoStatus->madeForKids = itemsStatus["madeForKids"]; + + if(checksum){ + // don't set videoStatus.set flag in order to avoid false free + Serial.print("Error reading in response values. Checksum: "); + Serial.println(checksum); + wasSuccessful = false; + }else{ + vStatusSet = true; + vStatus = newVideoStatus; + wasSuccessful = true; + } + } + + apiObj->closeClient(); + return wasSuccessful; +} + +/** + * @brief Gets the content details of a specific video. Stores them in the calling object. + * + * @param videoId videoID of the video to get the information from + * @return true, if there were no errors and the video was found + */ +bool YoutubeVideo::getVideoContentDetails(){ + if(videoContentDetsSet){ + free(videoContentDets); + videoContentDets = NULL; + videoContentDetsSet = false; + } + + char command[150]; + YoutubeApi::createRequestString(videoListContentDetails, command, videoId); + int httpStatus = apiObj->sendGetToYoutube(command); + + if(httpStatus == 200){ + return parseVideoContentDetails(); + } + return false; +} + + +/** + * @brief Parses the video content details from caller client. Stores information in calling object. + * + * @return true on success, false on error + */ +bool YoutubeVideo::parseVideoContentDetails(){ + bool wasSuccessful = false; + + // Get from https://arduinojson.org/v6/assistant/ + const size_t bufferSize = 384; + + + // Creating a filter to filter out + // region restrictions, content rating and metadata + StaticJsonDocument<180> filter; + + JsonObject filterItems = filter["items"][0].createNestedObject("contentDetails"); + filterItems["duration"] = true; + filterItems["dimension"] = true; + filterItems["definition"] = true; + filterItems["caption"] = true; + filterItems["licensedContent"] = true; + filterItems["projection"] = true; + filter["pageInfo"] = true; + + // Allocate DynamicJsonDocument + DynamicJsonDocument doc(bufferSize); + + // Parse JSON object + DeserializationError error = deserializeJson(doc, apiObj->client, DeserializationOption::Filter(filter)); + + // check for errors and empty response + if(error){ + Serial.print(F("deserializeJson() failed with code ")); + Serial.println(error.c_str()); + } + else if(doc["pageInfo"]["totalResults"].as() == 0){ + Serial.println("No results found for video id "); + } + else{ + videoContentDetails *newContentDetails = (videoContentDetails*) malloc(sizeof(videoContentDetails)); + + memcpy(newContentDetails ->defintion, doc["items"][0]["contentDetails"]["definition"].as(), 3); + memcpy(newContentDetails ->dimension, doc["items"][0]["contentDetails"]["dimension"].as(), 3); + strcpy(newContentDetails ->projection, doc["items"][0]["contentDetails"]["projection"].as()); + + if("false" == doc["items"][0]["contentDetails"]["caption"]){ + newContentDetails ->caption = true; + } + else{ + newContentDetails->caption = false; + } + + newContentDetails ->licensedContent = doc["items"][0]["contentDetails"]["licensedContent"].as(); + newContentDetails ->duration = YoutubeApi::parseDuration(doc["items"][0]["contentDetails"]["duration"].as()); + + videoContentDets = newContentDetails; + videoContentDetsSet = true; + + wasSuccessful = true; + } + + apiObj->closeClient(); + return wasSuccessful; +} diff --git a/lib/YoutubeVideo/YoutubeVideo.h b/lib/YoutubeVideo/YoutubeVideo.h new file mode 100644 index 0000000..1efe41d --- /dev/null +++ b/lib/YoutubeVideo/YoutubeVideo.h @@ -0,0 +1,75 @@ +#ifndef YoutubeVideo_h +#define YoutubeVideo_h + +#include "YoutubeTypes.h" +#include "YoutubeApi.h" + +#include +#include +#include + +class YoutubeVideo{ + + public: + YoutubeVideo(); + + YoutubeVideo(const char *newVideoId, YoutubeApi *apiObj); + YoutubeVideo(String& newVideoId, YoutubeApi *apiObj): YoutubeVideo(newVideoId.c_str(), apiObj) {}; + + ~YoutubeVideo(); + + bool getVideoStatistics(); + bool getVideoSnippet(); + bool getVideoContentDetails(); + bool getVideoStatus(); + + bool checkVideoIdSet(); + bool checkVideoSnippetSet(); + bool checkVideoStatisticsSet(); + bool checkVideoContentDetailsSet(); + bool checkVideoStatusSet(); + + bool resetVideoId(const char *newVideoId); + bool resetVideoId(String& newVideoId); + void resetInfo(); + + const char* getVideoId(); + YoutubeApi* getYoutubeApiObj(); + String getVideoIdString(); + + videoSnippet *videoSnip = NULL; + videoStatistics *videoStats = NULL; + videoContentDetails *videoContentDets = NULL; + videoStatus *vStatus = NULL; + + private: + + char videoId[YT_VIDEOID_LEN + 1] = ""; + + bool videoIdSet = false; + bool videoSnipSet = false; + bool videoStatsSet = false; + bool videoContentDetsSet = false; + bool vStatusSet = false; + + YoutubeApi *apiObj; + + bool parseVideoStatistics(); + bool parseVideoSnippet(); + bool parseVideoStatus(); + bool parseVideoContentDetails(); + + bool setVideoId(const char *newVideoId); + + bool freeVideoSnippet(videoSnippet *s); + bool freeVideoStatus(videoStatus *s); + + #ifdef UNIT_TESTING + friend void test_StringConstructor_simple() + #endif + +}; + + + +#endif \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index 0702987..1354f21 100644 --- a/platformio.ini +++ b/platformio.ini @@ -6,12 +6,14 @@ ; Advanced options: extra scripting ; ; Please visit documentation for the other options and examples -; http://docs.platformio.org/page/projectconf.html +; https://docs.platformio.org/page/projectconf.html + [common] lib_deps_external = ArduinoJson -[env:d1_mini] -platform = espressif8266 -board = d1_mini +[env:esp32_devkit] +platform = espressif32 +board = esp32doit-devkit-v1 framework = arduino lib_deps = ${common.lib_deps_external} +monitor_speed = 115200 diff --git a/scripts/travis/platformioSingle.sh b/scripts/travis/platformioSingle.sh deleted file mode 100755 index f375ded..0000000 --- a/scripts/travis/platformioSingle.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -eux - -platformio ci $PWD/examples/$EXAMPLE_NAME/$EXAMPLE_NAME.ino -l '.' -b $BOARD diff --git a/src/YoutubeApi.cpp b/src/YoutubeApi.cpp deleted file mode 100644 index 59977c3..0000000 --- a/src/YoutubeApi.cpp +++ /dev/null @@ -1,174 +0,0 @@ -/* - * YoutubeApi - An Arduino wrapper for the YouTube API - * Copyright (c) 2020 Brian Lough - * - * MIT License - * - * 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. - * - */ - - -#include "YoutubeApi.h" - -YoutubeApi::YoutubeApi(const char* key, Client &client) - : apiKey(key), client(client) -{} - -YoutubeApi::YoutubeApi(const String &apiKey, Client &client) - : YoutubeApi(apiKey.c_str(), client) // passing the key as c-string to force a copy -{} - -int YoutubeApi::sendGetToYoutube(const char *command) { - client.flush(); - client.setTimeout(YTAPI_TIMEOUT); - if (!client.connect(YTAPI_HOST, YTAPI_SSL_PORT)) - { - Serial.println(F("Connection failed")); - return false; - } - // give the esp a breather - yield(); - - // Send HTTP request - client.print(F("GET ")); - client.print(command); - client.println(F(" HTTP/1.1")); - - //Headers - client.print(F("Host: ")); - client.println(YTAPI_HOST); - - client.println(F("Cache-Control: no-cache")); - - if (client.println() == 0) - { - Serial.println(F("Failed to send request")); - return -1; - } - - int statusCode = getHttpStatusCode(); - - // Let the caller of this method parse the JSon from the client - skipHeaders(); - return statusCode; -} - -int YoutubeApi::sendGetToYoutube(const String& command) { - return sendGetToYoutube(command.c_str()); -} - -bool YoutubeApi::getChannelStatistics(const char *channelId) { - char command[150] = YTAPI_CHANNEL_ENDPOINT; - char params[120]; - sprintf(params, "?part=statistics&id=%s&key=%s", channelId, apiKey.c_str()); - strcat(command, params); - - if (_debug) - { - Serial.println(command); - } - - bool wasSuccessful = false; - - // Get from https://arduinojson.org/v6/assistant/ - const size_t bufferSize = JSON_ARRAY_SIZE(1) - + JSON_OBJECT_SIZE(2) - + 2*JSON_OBJECT_SIZE(4) - + JSON_OBJECT_SIZE(5) - + 330; - - int httpStatus = sendGetToYoutube(command); - - if (httpStatus == 200) - { - // Allocate DynamicJsonDocument - DynamicJsonDocument doc(bufferSize); - - // Parse JSON object - DeserializationError error = deserializeJson(doc, client); - if (!error) - { - wasSuccessful = true; - - JsonObject itemStatistics = doc["items"][0]["statistics"]; - - channelStats.viewCount = itemStatistics["viewCount"].as(); - channelStats.subscriberCount = itemStatistics["subscriberCount"].as(); - channelStats.commentCount = itemStatistics["commentCount"].as(); - channelStats.hiddenSubscriberCount = itemStatistics["hiddenSubscriberCount"].as(); - channelStats.videoCount = itemStatistics["videoCount"].as(); - } - else - { - Serial.print(F("deserializeJson() failed with code ")); - Serial.println(error.c_str()); - } - } else { - Serial.print("Unexpected HTTP Status Code: "); - Serial.println(httpStatus); - } - closeClient(); - - return wasSuccessful; -} - -bool YoutubeApi::getChannelStatistics(const String& channelId) { - return getChannelStatistics(channelId.c_str()); -} - -void YoutubeApi::skipHeaders() { - // Skip HTTP headers - char endOfHeaders[] = "\r\n\r\n"; - if (!client.find(endOfHeaders)) - { - Serial.println(F("Invalid response")); - return; - } - - // 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); - if (_debug) - { - Serial.print("Tossing an unexpected character: "); - Serial.println(c); - } - } -} - -int YoutubeApi::getHttpStatusCode() { - // Check HTTP status - if(client.find("HTTP/1.1")){ - int statusCode = client.parseInt(); - return statusCode; - } - - return -1; -} - -void YoutubeApi::closeClient() { - if(client.connected()) { - if(_debug) { Serial.println(F("Closing client")); } - client.stop(); - } -} diff --git a/test/test_apiClass/test_YoutubeApiClass.cpp b/test/test_apiClass/test_YoutubeApiClass.cpp new file mode 100644 index 0000000..e94ae78 --- /dev/null +++ b/test/test_apiClass/test_YoutubeApiClass.cpp @@ -0,0 +1,204 @@ +#include +#include +#include "secrets.h" +#include "YoutubeApi.h" +#include "YoutubeTypes.h" +#include + +WiFiClientSecure client; + +void setUp(){ + client = WiFiClientSecure(); + client.setInsecure(); +} + +void test_createRequestString_videoStats_simple(){ + YoutubeApi uut("123", client); + + char uutCommand[150]; + const char *expectedRes = "/youtube/v3/videos?part=statistics&id=456&key=123"; + YoutubeApi::createRequestString(videoListStats, uutCommand, "456"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_videoStats_length40(){ + char Length40ApiKey[41] = "1234567890123456789012345678901234567890"; + YoutubeApi uut(Length40ApiKey, client); + + char uutCommand[150]; + const char *expectedRes = "/youtube/v3/videos?part=statistics&id=456&key=1234567890123456789012345678901234567890"; + YoutubeApi::createRequestString(videoListStats, uutCommand, "456"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_videoStats_length46(){ + char length41ApiKey[47] = "1234567890123456789012345678901234567890123456"; + YoutubeApi uut(length41ApiKey, client); + + char uutCommand[150]; + const char *expectedRes = "/youtube/v3/videos?part=statistics&id=456&key=123456789012345678901234567890123456789012345"; // should be cut off + YoutubeApi::createRequestString(videoListStats, uutCommand, "456"); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_videoSnip_length40(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/videos?part=snippet&id=%s&key=%s", TEST_VID_ID, API_KEY); + YoutubeApi::createRequestString(videoListSnippet, uutCommand, TEST_VID_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_channelStatistics_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/channels?part=statistics&id=%s&key=%s", TEST_CHANNEL_ID, API_KEY); + YoutubeApi::createRequestString(channelListStats, uutCommand, TEST_CHANNEL_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_channelSnippet_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/channels?part=snippet&id=%s&key=%s", TEST_CHANNEL_ID, API_KEY); + YoutubeApi::createRequestString(channelListSnippet, uutCommand, TEST_CHANNEL_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_channelContentDetails_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/channels?part=contentDetails&id=%s&key=%s", TEST_CHANNEL_ID, API_KEY); + YoutubeApi::createRequestString(channelListContentDetails, uutCommand, TEST_CHANNEL_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + +void test_createRequestString_playlistStatus_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/playlists?part=status&id=%s&key=%s", TEST_PLAYLIST_ID, API_KEY); + YoutubeApi::createRequestString(playlistListStatus, uutCommand, TEST_PLAYLIST_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + + +void test_createRequestString_playlistContentDetails_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/playlists?part=contentDetails&id=%s&key=%s", TEST_PLAYLIST_ID, API_KEY); + YoutubeApi::createRequestString(playlistListContentDetails, uutCommand, TEST_PLAYLIST_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + + +void test_createRequestString_playlistSnippet_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/playlists?part=snippet&id=%s&key=%s", TEST_PLAYLIST_ID, API_KEY); + YoutubeApi::createRequestString(playlistListSnippet, uutCommand, TEST_PLAYLIST_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + + +void test_createRequestString_playlistItemsContentDetails_simple(){ + YoutubeApi uut(API_KEY, client); + + char uutCommand[150]; + char expectedRes[150]; + sprintf(expectedRes, "/youtube/v3/playlistItems?part=contentDetails&playlistId=%s&key=%s", TEST_PLAYLIST_ID, API_KEY); + YoutubeApi::createRequestString(playlistItemsListContentDetails, uutCommand, TEST_PLAYLIST_ID); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(expectedRes, uutCommand, "The request string is not correct!"); +} + + +void test_allocAndCopy_pos_NULL(){ + + const char someData[] = "testdata"; + + int ret = YoutubeApi::allocAndCopy(NULL, someData); + + TEST_ASSERT_EQUAL_MESSAGE(1, ret, "There should be an error copying to NULL :p!"); + +} + +void test_allocAndCopy_data_NULL(){ + + char *destinationOfCopy; + + int ret = YoutubeApi::allocAndCopy(&destinationOfCopy, NULL); + + TEST_ASSERT_EQUAL_MESSAGE(1, ret, "There should be an error copying from NULL :p!"); + +} + +void test_allocAndCopy_simple(){ + + char *destinationOfCopy; + const char someData[] = "testdata"; + + int ret = YoutubeApi::allocAndCopy(&destinationOfCopy, someData); + + TEST_ASSERT_EQUAL_MESSAGE(0, ret, "There should not be an error!"); + if(destinationOfCopy == NULL){ + TEST_FAIL_MESSAGE("Data destination has become a NULL pointer!"); + } + TEST_ASSERT_EQUAL_STRING_MESSAGE(someData, destinationOfCopy, "Data should match!"); + + free(destinationOfCopy); +} + + +void setup() +{ + + Serial.begin(115200); + + UNITY_BEGIN(); + RUN_TEST(test_createRequestString_videoStats_simple); + RUN_TEST(test_createRequestString_videoStats_length40); + RUN_TEST(test_createRequestString_videoStats_length46); + RUN_TEST(test_createRequestString_videoSnip_length40); + + RUN_TEST(test_createRequestString_channelStatistics_simple); + RUN_TEST(test_createRequestString_channelSnippet_simple); + RUN_TEST(test_createRequestString_channelContentDetails_simple); + RUN_TEST(test_createRequestString_playlistStatus_simple); + RUN_TEST(test_createRequestString_playlistContentDetails_simple); + RUN_TEST(test_createRequestString_playlistSnippet_simple); + RUN_TEST(test_createRequestString_playlistItemsContentDetails_simple); + + RUN_TEST(test_allocAndCopy_pos_NULL); + RUN_TEST(test_allocAndCopy_data_NULL); + RUN_TEST(test_allocAndCopy_simple); + + UNITY_END(); +} + +void loop() +{ +} \ No newline at end of file diff --git a/test/test_channelClass/test_YoutubeChannelClass.cpp b/test/test_channelClass/test_YoutubeChannelClass.cpp new file mode 100644 index 0000000..61ee94c --- /dev/null +++ b/test/test_channelClass/test_YoutubeChannelClass.cpp @@ -0,0 +1,169 @@ +#include +#include +#include "YoutubeChannel.h" +#include "secrets.h" +#include + +#define MAX_WIFI_RETRIES 10 + +#define tooLongChannelId "1234567890123456789012345" +#define tooShortChannelId "12345678901234567890123" + +WiFiClientSecure client; + +void setUp(){ + + if(WiFi.status() != WL_CONNECTED){ + TEST_IGNORE_MESSAGE("Could not establish internet connection!"); + } + + client = WiFiClientSecure(); + client.setInsecure(); +} + +void test_onlyApiConstructor(){ + YoutubeApi apiDummy("", client); + YoutubeChannel uut(&apiDummy); + + TEST_ASSERT_EQUAL_MESSAGE(&apiDummy, uut.getYoututbeApiObj(), "Expected vaild apiObject!"); + TEST_ASSERT_FALSE_MESSAGE(uut.checkChannelIdSet(), "Channel id flag should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getChannelId(), "Channel id should be empty"); +} + +void test_charConstructor_validId(){ + YoutubeApi apiDummy("", client); + YoutubeChannel uut(TEST_CHANNEL_ID, &apiDummy); + + TEST_ASSERT_EQUAL_MESSAGE(&apiDummy, uut.getYoututbeApiObj(), "Expected vaild apiObject!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkChannelIdSet(), "Channel id flag should be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE(TEST_CHANNEL_ID, uut.getChannelId(), "Channel id wrong!"); +} + +void test_charConstructor_tooLongId(){ + YoutubeApi apiDummy("", client); + YoutubeChannel uut(tooLongChannelId, &apiDummy); + + TEST_ASSERT_EQUAL_MESSAGE(&apiDummy, uut.getYoututbeApiObj(), "Expected vaild apiObject!"); + TEST_ASSERT_FALSE_MESSAGE(uut.checkChannelIdSet(), "Channel id flag should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getChannelId(), "Channel id should be empty"); +} + +void test_charConstructor_tooShortId(){ + YoutubeApi apiDummy("", client); + YoutubeChannel uut(tooShortChannelId, &apiDummy); + + TEST_ASSERT_EQUAL_MESSAGE(&apiDummy, uut.getYoututbeApiObj(), "Expected vaild apiObject!"); + TEST_ASSERT_FALSE_MESSAGE(uut.checkChannelIdSet(), "Channel id flag should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getChannelId(), "Channel id should be empty"); +} + +void test_resetInfo(){ + YoutubeApi apiDummy("", client); + YoutubeChannel uut(TEST_CHANNEL_ID, &apiDummy); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(TEST_CHANNEL_ID, uut.getChannelId(), "Channel id not correct!"); + + uut.resetInfo(); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkChannelIdSet(), "channel id flag should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getChannelId(), "channel id should be empty"); + TEST_ASSERT_EQUAL_MESSAGE(&apiDummy, uut.getYoututbeApiObj(), "api object pointer should stay"); +} + +bool establishInternetConnection(){ + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + for(int tryWIFI = 1; tryWIFI < MAX_WIFI_RETRIES; tryWIFI++){ + if(WiFi.status() == WL_CONNECTED){ + return true; + } + delay(1000); + } + return false; +} + +void test_getChannelStatistics_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeChannel uut(TEST_CHANNEL_ID, &apiObj); + + bool ret = uut.getChannelStatistics(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to receive valid response!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkChannelStatsSet(), "Expected the channel statistics flag to be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.channelStats, "Expected a channelStatistics object to be set!"); +} + +void test_getChannelStatistics_simple_reset(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeChannel uut(TEST_CHANNEL_ID, &apiObj); + + bool ret = uut.getChannelStatistics(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to receive valid response!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkChannelStatsSet(), "Expected the channel statistics flag to be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.channelStats, "Expected a channelStatistics object to be set!"); + + uut.resetInfo(); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkChannelStatsSet(), "Expected the channel statistics flag to not be set!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.channelStats, "Expected the channelStatistics object to not be set!"); + +} + +void test_getChannelSnippet_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeChannel uut(TEST_CHANNEL_ID, &apiObj); + + bool ret = uut.getChannelSnippet(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to receive valid response!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkChannelSnipSet(), "Expected the channel statistics flag to be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.channelSnip, "Expected a channelSnip to be set!"); +} + +void test_getChannelContentDetails_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeChannel uut(TEST_CHANNEL_ID, &apiObj); + + bool ret = uut.getChannelContentDetails(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to receive valid response!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkChannelContentDetailsSet(), "Expected the channel contentDetails flag to be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.channelContentDets, "Expected a contentDetails to be set!"); + + // checking for valid strings (not NULL) + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.channelContentDets->relatedPlaylistsLikes, "Expected a valid string"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.channelContentDets->relatedPlaylistsUploads, "Expected a valid string"); +} + + + +void setup(){ + Serial.begin(115200); + UNITY_BEGIN(); + + establishInternetConnection(); + + RUN_TEST(test_onlyApiConstructor); + + RUN_TEST(test_charConstructor_validId); + RUN_TEST(test_charConstructor_tooShortId); + RUN_TEST(test_charConstructor_tooLongId); + + RUN_TEST(test_resetInfo); + + RUN_TEST(test_getChannelStatistics_simple); + RUN_TEST(test_getChannelStatistics_simple_reset); + + RUN_TEST(test_getChannelSnippet_simple); + + RUN_TEST(test_getChannelContentDetails_simple); + + UNITY_END(); +} + +void loop(){} diff --git a/test/test_playlistClass/test_YoutubePlaylistClass.cpp b/test/test_playlistClass/test_YoutubePlaylistClass.cpp new file mode 100644 index 0000000..2434347 --- /dev/null +++ b/test/test_playlistClass/test_YoutubePlaylistClass.cpp @@ -0,0 +1,253 @@ +#include +#include +#include "secrets.h" +#include "YoutubePlaylist.h" +#include "YoutubeTypes.h" +#include + + +#define MAX_WIFI_RETRIES 10 + +char playlistId[YT_PLAYLISTID_LEN + 1]; + +WiFiClientSecure client; + +void setUp(){ + + if(WiFi.status() != WL_CONNECTED){ + TEST_IGNORE_MESSAGE("Could not establish internet connection!"); + } + + client = WiFiClientSecure(); + client.setInsecure(); +} + + +void test_constructDestruct_simple(){ + + WiFiClientSecure dummyClient; + YoutubeApi dummyApi(API_KEY, dummyClient); + + YoutubePlaylist uut(&dummyApi, TEST_PLAYLIST_ID); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkPlaylistContentDetsSet(), "Should be false in initial state!"); + TEST_ASSERT_FALSE_MESSAGE(uut.checkPlaylistStatusSet(), "Should be false in initial state!"); + TEST_ASSERT_FALSE_MESSAGE(uut.checkPlaylistSnipSet(), "Should be false in initial state!"); + + TEST_ASSERT_NULL_MESSAGE(uut.snip, "Should be NULL in initial state!"); + TEST_ASSERT_NULL_MESSAGE(uut.contentDets, "Should be NULL in initial state!"); + TEST_ASSERT_NULL_MESSAGE(uut.status, "Should be NULL in initial state!"); +} + +void test_getPlaylistStatus_simple(){ + YoutubeApi dummyApi(API_KEY, client); + YoutubePlaylist uut(&dummyApi, TEST_PLAYLIST_ID); + + bool ret = uut.getPlaylistStatus(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to be able to get a playlist status!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.status, "Expected a valid playlistStatus object to be set!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.status->privacyStatus, "Expected a valid string to be set!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkPlaylistStatusSet(), "Expected the playlistStatus flag to be set!"); +} + + +void test_getPlaylistContentDetails_simple(){ + YoutubeApi dummyApi(API_KEY, client); + YoutubePlaylist uut(&dummyApi, TEST_PLAYLIST_ID); + + bool ret = uut.getPlaylistContentDetails(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to be able to get a playlist content details!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.contentDets, "Expected a valid playlistContentDetails object to be set!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkPlaylistContentDetsSet(), "Expected the playlistContentDetails flag to be set!"); +} + + +void test_getPlaylistSnippet_simple(){ + YoutubeApi dummyApi(API_KEY, client); + YoutubePlaylist uut(&dummyApi, TEST_PLAYLIST_ID); + + bool ret = uut.getPlaylistSnippet(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to be able to get a playlist snippet!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.snip, "Expected a valid playlist snippet object to be set!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkPlaylistSnipSet(), "Expected the pplaylist snippet flag to be set!"); + + TEST_ASSERT_NOT_NULL_MESSAGE(uut.snip->channelId, "Expected a valid channel id string to be set!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.snip->title, "Expected a valid title string to be set!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.snip->description, "Expected a description string to be set!"); + TEST_ASSERT_NOT_NULL_MESSAGE(uut.snip->channelTitle, "Expected a valid channel title stringt to be set!"); +} + + +/** + * @brief Checks if the given playlistItem is valid, or a "filler" set with default values + * + * @param c playlistItem to check + * @return returns true, if default values were detected. Otherwise returns false + */ +bool checkForDefaultPlaylistItemsContentDetails_value(playlistItemsContentDetails *c){ + + if(!strcmp(c->videoId, "") && c->videoPublishedAt.tm_year == 70){ + return true; + } + + return false; +} + + +void test_getPlaylistItems_firstPage(){ + YoutubeApi dummyApi(API_KEY, client); + YoutubePlaylist uut(&dummyApi, playlistId); + + bool ret = uut.getPlaylistItemsPage(0); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected to be able to get first PlaylistItemsPage"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkItemsConfigSet(), "Expected the configuration flag to be set!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkItemsContentDetsSet(), "Expected the data flag to be set!"); + + TEST_ASSERT_MESSAGE(uut.playlistItemsConfig->currentPage == 0, "Expected to be on first page"); + + playlistItemsConfiguration *uutConfig = uut.playlistItemsConfig; + + + if(uutConfig->totalResults < YT_PLAYLIST_ITEM_RESULTS_PER_PAGE){ + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uutConfig->nextPageToken, "Did not expect a next page token to be set, when results fit into one page!"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uutConfig->previousPageToken, "Did not expect a previous page token to be set, when feetching first page!"); + + TEST_ASSERT_EQUAL_UINT8_MESSAGE(uutConfig->totalResults - 1, uutConfig->currentPageLastValidPos, "Expected amount of total results correlating with lastValidPage position!"); + + }else{ // full page + + if(uutConfig->totalResults == YT_PLAYLIST_ITEM_RESULTS_PER_PAGE){ + TEST_ASSERT_MESSAGE(strcmp(uutConfig->nextPageToken, "") == 0, "Expected no next page token, as all results fit into one page!"); // Couldnt test this case. + }else{ + TEST_ASSERT_MESSAGE(strcmp(uutConfig->nextPageToken, "") != 0, "Expected a next page token to be set!"); + } + + TEST_ASSERT_EQUAL_UINT8_MESSAGE(YT_PLAYLIST_ITEM_RESULTS_PER_PAGE - 1, uutConfig->currentPageLastValidPos, "Expected page to be filed!"); + } + + // Testing if default values are set + for(int i = 0; i < YT_PLAYLIST_ITEM_RESULTS_PER_PAGE; i++){ + + if(i > uutConfig->currentPageLastValidPos){ + ret = checkForDefaultPlaylistItemsContentDetails_value(&uut.itemsContentDets[i]); + TEST_ASSERT_TRUE_MESSAGE(ret, "Expected a default value!"); + }else{ + ret = checkForDefaultPlaylistItemsContentDetails_value(&uut.itemsContentDets[i]); + TEST_ASSERT_EQUAL_MESSAGE(strlen(uut.itemsContentDets[i].videoId), YT_VIDEOID_LEN, "Expected other length of videoId string!"); + TEST_ASSERT_GREATER_OR_EQUAL_INT_MESSAGE(104, uut.itemsContentDets[i].videoPublishedAt.tm_year, "Video upload date should be after 2004"); + TEST_ASSERT_FALSE_MESSAGE(ret, "Did not expect filler values on full page!"); + } + } +} + +void test_getPlaylistItems_secondPage(){ + YoutubeApi dummyApi(API_KEY, client); + YoutubePlaylist uut(&dummyApi, playlistId); + + bool ret = uut.getPlaylistItemsPage(1); + + TEST_ASSERT_TRUE_MESSAGE(uut.checkItemsConfigSet(), "Expected playlistItemsConfig to be set and configured!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkItemsContentDetsSet(), "Expected a page to be set and configured!"); + + playlistItemsConfiguration *uutConfig = uut.playlistItemsConfig; + + if(uutConfig->totalResults > YT_PLAYLIST_ITEM_RESULTS_PER_PAGE){ + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch second page, as there are enough items!"); + }else{ + TEST_ASSERT_FALSE_MESSAGE(ret, "Should not be able to fetch second page, as there not enough items!"); + return; + } + + TEST_ASSERT_EQUAL_UINT16_MESSAGE(1, uut.playlistItemsConfig->currentPage, "Expected to be on second page"); + + TEST_ASSERT_MESSAGE(strcmp("", uutConfig->currentPageToken) != 0, "Expected the current page token to be set, when fetching second page!"); + TEST_ASSERT_MESSAGE(strcmp("", uutConfig->previousPageToken) != 0, "Expected a previous page token to be set, when fetching second page!"); +} + + +void test_getPlaylistItems_first_second_first_page(){ + YoutubeApi dummyApi(API_KEY, client); + YoutubePlaylist uut(&dummyApi, playlistId); + + bool ret = uut.getPlaylistItemsPage(1); + + TEST_ASSERT_TRUE_MESSAGE(uut.checkItemsConfigSet(), "Expected playlistItemsConfig to be set and configured!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkItemsContentDetsSet(), "Expected a page to be set and configured!"); + + playlistItemsConfiguration *uutConfig = uut.playlistItemsConfig; + + if(uutConfig->totalResults > YT_PLAYLIST_ITEM_RESULTS_PER_PAGE){ + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch second page, as there are enough items!"); + }else{ + TEST_ASSERT_FALSE_MESSAGE(ret, "Should not be able to fetch second page, as there not enough items!"); + return; + } + + TEST_ASSERT_EQUAL_UINT16_MESSAGE(1, uut.playlistItemsConfig->currentPage, "Expected to be on second page"); + + TEST_ASSERT_MESSAGE(strcmp("", uutConfig->currentPageToken) != 0, "Expected the current page token to be set, when fetching second page!"); + TEST_ASSERT_MESSAGE(strcmp("", uutConfig->previousPageToken) != 0, "Expected a previous page token to be set, when fetching second page!"); + + char secondPageToken[YT_PLALIST_ITEMS_PAGE_TOKEN_LEN + 1]; + strcpy(secondPageToken, uut.playlistItemsConfig->currentPageToken); + + ret = uut.getPlaylistItemsPage(0); + + TEST_ASSERT_EQUAL_UINT16_MESSAGE(0, uut.playlistItemsConfig->currentPage, "Expected to be on first page"); + TEST_ASSERT_MESSAGE(strcmp(secondPageToken, uutConfig->currentPageToken) != 0, "Current token should not match token of second page!"); + TEST_ASSERT_MESSAGE(strcmp(secondPageToken, uutConfig->nextPageToken) == 0, "Next token should match token of second page!"); + TEST_ASSERT_MESSAGE(strcmp("", uutConfig->currentPageToken) != 0, "Expected the current page token to be set, when fetching second page!"); + + + +} + + +bool establishInternetConnection(){ + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + for(int tryWIFI = 1; tryWIFI < MAX_WIFI_RETRIES; tryWIFI++){ + if(WiFi.status() == WL_CONNECTED){ + return true; + } + delay(1000); + } + return false; +} + +void setup(){ + + Serial.begin(115200); + + UNITY_BEGIN(); + + establishInternetConnection(); + + RUN_TEST(test_constructDestruct_simple); + RUN_TEST(test_getPlaylistStatus_simple); + RUN_TEST(test_getPlaylistContentDetails_simple); + RUN_TEST(test_getPlaylistSnippet_simple); + + strcpy(playlistId, TEST_PLAYLIST_ID_FEW_UPLOADS); + RUN_TEST(test_getPlaylistItems_firstPage); + RUN_TEST(test_getPlaylistItems_secondPage); + + Serial.println("Testing now with an playlistId, with more than YT_PLAYLIST_ITEM_RESULTS_PER_PAGE items"); + + strcpy(playlistId, TEST_PLAYLIST_ID_MANY_UPLOADS ); + RUN_TEST(test_getPlaylistItems_firstPage); + RUN_TEST(test_getPlaylistItems_secondPage); + RUN_TEST(test_getPlaylistItems_first_second_first_page); + + UNITY_END(); +} + +void loop(){} \ No newline at end of file diff --git a/test/test_videoClass/test_YoutubeVideoClass.cpp b/test/test_videoClass/test_YoutubeVideoClass.cpp new file mode 100644 index 0000000..195fccc --- /dev/null +++ b/test/test_videoClass/test_YoutubeVideoClass.cpp @@ -0,0 +1,325 @@ +#include +#include +#include "YoutubeVideo.h" +#include +#include +#include "secrets.h" // API key and wifi password are defined in here + +#define UNIT_TESTING 1 +#define MAX_WIFI_RETRIES 10 + +const char *validIdChar = "12345678901"; +const char *invalidIdChar = "123"; +String validIdString = "12345678901"; +String invalidIdString = "123"; + +WiFiClientSecure client; + +void setUp(){ + + if(WiFi.status() != WL_CONNECTED){ + TEST_IGNORE_MESSAGE("Could not establish internet connection!"); + } + + client = WiFiClientSecure(); + client.setInsecure(); +} + +void test_emptyConstructor() +{ + YoutubeVideo uut; + + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.videoSnip, "videoSnip pointer supposed to be NULL!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.videoStats, "videoStats pointer supposed to be NULL!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.videoContentDets, "videoContentDets pointer supposed to be NULL!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.vStatus, "vStatus pPointer supposed to be NULL!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); +} + +void test_constCharConstructor_simple() +{ + YoutubeVideo uut(validIdChar, NULL); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(validIdChar, uut.getVideoId(), "Did not return right videoId!"); + TEST_ASSERT_EQUAL_MESSAGE(true, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_constCharConstructor_rejectId() +{ + YoutubeVideo uut(invalidIdChar, NULL); + + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "Did not return right videoId!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_StringConstructor_simple() +{ + YoutubeVideo uut(validIdString, NULL); + + TEST_ASSERT_EQUAL_STRING_MESSAGE(validIdChar, uut.getVideoId(), "Did not return right videoId!"); + TEST_ASSERT_EQUAL_MESSAGE(true, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_StringConstructor_rejectId() +{ + YoutubeVideo uut(invalidIdString, NULL); + + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "Did not return right videoId!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_resetVideoIdConstChar_simple() +{ + YoutubeVideo uut(validIdChar, NULL); + + bool ret = uut.resetVideoId("10987654321"); + TEST_ASSERT_EQUAL(true, ret); + TEST_ASSERT_EQUAL_STRING_MESSAGE("10987654321", uut.getVideoId(), "VideoId did not change correctly!"); + TEST_ASSERT_EQUAL_MESSAGE(true, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_resetVideoIdConstChar_videoId_notSet() +{ + YoutubeVideo uut; + + bool ret = uut.resetVideoId(validIdChar); + TEST_ASSERT_EQUAL(true, ret); + TEST_ASSERT_EQUAL_STRING_MESSAGE(validIdChar, uut.getVideoId(), "VideoId did not change correctly!"); + TEST_ASSERT_EQUAL_MESSAGE(true, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_resetVideoIdConstChar_videoId_toLong() +{ + YoutubeVideo uut(validIdChar, NULL); + + const char videoId[13] = "012345678910"; + + bool ret = uut.resetVideoId(videoId); + TEST_ASSERT_EQUAL(false, ret); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "VideoId did not change correctly!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); +} + +void test_resetVideoIdConstChar_videoId_toShort() +{ + YoutubeVideo uut(validIdChar, NULL); + + const char videoId[11] = "0123456789"; + bool ret = uut.resetVideoId(videoId); + TEST_ASSERT_EQUAL(false, ret); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "VideoId did not change correctly!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); +} + +void test_getVideoIdConstChar_videoId_notSet(){ + YoutubeVideo uut; + + const char* vidId = uut.getVideoId(); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", vidId, "Expected a empty string, as video id has not been set yet!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); +} + +void test_getVideoIdConstChar_videoId_set(){ + YoutubeVideo uut(validIdChar, NULL); + + const char* vidId = uut.getVideoId(); + TEST_ASSERT_EQUAL_STRING_MESSAGE(validIdChar, vidId, "Did not return correct string"); + TEST_ASSERT_EQUAL_MESSAGE(true, uut.checkVideoIdSet(), "videoId should be set"); +} + +void test_resetInfo_afterConstruct(){ + YoutubeVideo uut(validIdChar, NULL); + uut.resetInfo(); + + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "Expected a empty string, as video id has not been set yet!"); + +} + + +void test_resetInfo_keepYoutubeApi_obj(){ + YoutubeApi apiObject(API_KEY, client); + YoutubeApi *pointerToObj = &apiObject; + YoutubeVideo uut(validIdChar, pointerToObj); + + uut.resetInfo(); + + TEST_ASSERT_EQUAL_MESSAGE(pointerToObj, uut.getYoutubeApiObj(), "YoutubeApi object should remain the same!"); +} + +bool establishInternetConnection(){ + WiFi.mode(WIFI_STA); + WiFi.disconnect(); + delay(100); + + WiFi.begin(WIFI_SSID, WIFI_PASSWORD); + + for(int tryWIFI = 1; tryWIFI < MAX_WIFI_RETRIES; tryWIFI++){ + + if(WiFi.status() == WL_CONNECTED){ + return true; + } + delay(1000); + } + return false; +} + + +void test_getVideoStats_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoStatistics(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.videoStats, "There should be a videoStatistics object set!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoStatisticsSet(), "Video statistics flag should be set!"); +} + +void test_getVideoStats_simple_reset(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoStatistics(); + + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.videoStats, "There should be a videoStatistics object set!"); + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + + uut.resetInfo(); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkVideoStatisticsSet(), "Video statistics flag should not be set!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.videoStats, "Videostats should have been reset!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "Expected a empty string, as video id has not been set yet!"); +} + + +void test_getVideoSnippet_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoSnippet(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoSnippetSet(), "Video snippet flag should be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.videoSnip, "There should be a snippet object set!"); +} + +void test_getVideoSnippet_simple_reset(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoSnippet(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoSnippetSet(), "Video snippet flag should be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.videoSnip, "There should be a snippet object set!"); + + uut.resetInfo(); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkVideoSnippetSet(), "Video snippet flag should not be set!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.videoSnip, "Videosnippet should have been reset!"); +} + + +void test_getVideoStatus_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoStatus(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoStatusSet(), "Video status flag should be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.vStatus, "There should be a videoStatus object set!"); +} + +void test_getVideoStatus_simple_reset(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoStatus(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoStatusSet(), "Video status flag should be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.vStatus, "There should be a videoStatus object set!"); + + uut.resetInfo(); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkVideoStatusSet(), "Video status flag should not be set!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.vStatus, "There should not be a videoStatus object set!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "Expected a empty string, as video id has not been set yet!"); +} + + +void test_getVideoContentDetails_simple(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoContentDetails(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoContentDetailsSet(), "Video content details flag should be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.videoContentDets, "There should be a content details object set!"); +} + +void test_getVideoContentDetails_simple_reset(){ + YoutubeApi apiObj(API_KEY, client); + YoutubeVideo uut("USKD3vPD6ZA", &apiObj); + bool ret = uut.getVideoContentDetails(); + + TEST_ASSERT_TRUE_MESSAGE(ret, "Should be able to fetch video info!"); + TEST_ASSERT_TRUE_MESSAGE(uut.checkVideoContentDetailsSet(), "Video content details flag should be set!"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(NULL, uut.videoContentDets, "There should be a content details object set!"); + + uut.resetInfo(); + + TEST_ASSERT_FALSE_MESSAGE(uut.checkVideoContentDetailsSet(), "Video content details flag should not be set!"); + TEST_ASSERT_EQUAL_MESSAGE(NULL, uut.videoContentDets, "There should not be a content details object set!"); + TEST_ASSERT_EQUAL_MESSAGE(false, uut.checkVideoIdSet(), "videoId should not be set"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", uut.getVideoId(), "Expected a empty string, as video id has not been set yet!"); +} + +void setup() +{ + + Serial.begin(115200); + + UNITY_BEGIN(); + + establishInternetConnection(); + + RUN_TEST(test_emptyConstructor); + RUN_TEST(test_constCharConstructor_simple); + RUN_TEST(test_constCharConstructor_rejectId); + + RUN_TEST(test_StringConstructor_simple); + RUN_TEST(test_resetVideoIdConstChar_simple); + RUN_TEST(test_StringConstructor_rejectId); + + RUN_TEST(test_resetVideoIdConstChar_videoId_notSet); + RUN_TEST(test_resetVideoIdConstChar_videoId_toLong); + RUN_TEST(test_resetVideoIdConstChar_videoId_toShort); + + RUN_TEST(test_getVideoIdConstChar_videoId_notSet); + RUN_TEST(test_getVideoIdConstChar_videoId_set); + + RUN_TEST(test_resetInfo_afterConstruct); + RUN_TEST(test_resetInfo_keepYoutubeApi_obj); + + + RUN_TEST(test_getVideoStats_simple); + delay(100); + RUN_TEST(test_getVideoStats_simple_reset); + delay(100); + RUN_TEST(test_getVideoSnippet_simple); + delay(100); + RUN_TEST(test_getVideoSnippet_simple_reset); + delay(100); + RUN_TEST(test_getVideoStatus_simple); + delay(100); + RUN_TEST(test_getVideoStatus_simple_reset); + delay(100); + RUN_TEST(test_getVideoContentDetails_simple); + delay(100); + RUN_TEST(test_getVideoContentDetails_simple_reset); + + UNITY_END(); +} + +void loop() +{ +}