diff --git a/components/spotify/cspot/include/CDNTrackStream.h b/components/spotify/cspot/include/CDNAudioFile.h similarity index 58% rename from components/spotify/cspot/include/CDNTrackStream.h rename to components/spotify/cspot/include/CDNAudioFile.h index be7c2ca6..90cf8286 100644 --- a/components/spotify/cspot/include/CDNTrackStream.h +++ b/components/spotify/cspot/include/CDNAudioFile.h @@ -1,10 +1,10 @@ #pragma once -#include // for size_t -#include // for uint8_t -#include // for shared_ptr, unique_ptr -#include // for string -#include // for vector +#include // for size_t +#include // for uint8_t +#include // for shared_ptr, unique_ptr +#include // for string +#include // for vector #include "Crypto.h" // for Crypto #include "HTTPClient.h" // for HTTPClient @@ -16,46 +16,45 @@ class WrappedSemaphore; namespace cspot { class AccessKeyFetcher; -class CDNTrackStream { +class CDNAudioFile { public: - CDNTrackStream(std::shared_ptr); - ~CDNTrackStream(); - - enum class Status { INITIALIZING, HAS_DATA, HAS_URL, FAILED }; - - struct TrackInfo { - std::string trackId; - std::string name; - std::string album; - std::string artist; - std::string imageUrl; - int duration; - }; - - TrackInfo trackInfo; - - Status status; - std::unique_ptr trackReady; - - void fetchFile(const std::vector& trackId, - const std::vector& audioKey); - - void fail(); + CDNAudioFile(const std::string& cdnUrl, const std::vector& audioKey); + /** + * @brief Opens connection to the provided cdn url, and fetches track metadata. + */ void openStream(); + /** + * @brief Read and decrypt part of the cdn stream + * + * @param dst buffer where to read received data to + * @param amount of bytes to read + * + * @returns amount of bytes read + */ size_t readBytes(uint8_t* dst, size_t bytes); + /** + * @brief Returns current position in CDN stream + */ size_t getPosition(); + /** + * @brief returns total size of the audio file in bytes + */ size_t getSize(); + /** + * @brief Seeks the track to provided position + * @param position position where to seek the track + */ void seek(size_t position); private: const int OPUS_HEADER_SIZE = 8 * 1024; - const int OPUS_FOOTER_PREFFERED = 1024 * 12; // 12K should be safe + const int OPUS_FOOTER_PREFFERED = 1024 * 12; // 12K should be safe const int SEEK_MARGIN_SIZE = 1024 * 4; const int HTTP_BUFFER_SIZE = 1024 * 14; @@ -74,12 +73,9 @@ class CDNTrackStream { 0x3f, 0x63, 0x0d, 0x93}; std::unique_ptr crypto; - std::shared_ptr accessKeyFetcher; - std::unique_ptr httpConnection; - bool isConnected = false; - size_t position = 0; // Spotify header size + size_t position = 0; size_t totalFileSize = 0; size_t lastRequestPosition = 0; size_t lastRequestCapacity = 0; @@ -87,7 +83,6 @@ class CDNTrackStream { bool enableRequestMargin = false; std::string cdnUrl; - std::vector trackId; std::vector audioKey; void decrypt(uint8_t* dst, size_t nbytes, size_t pos); diff --git a/components/spotify/cspot/include/TrackProvider.h b/components/spotify/cspot/include/TrackProvider.h deleted file mode 100644 index de2900fe..00000000 --- a/components/spotify/cspot/include/TrackProvider.h +++ /dev/null @@ -1,40 +0,0 @@ -#pragma once - -#include // for uint8_t -#include // for shared_ptr, unique_ptr, weak_ptr -#include // for vector - -#include "MercurySession.h" // for MercurySession -#include "TrackReference.h" // for TrackReference -#include "protobuf/metadata.pb.h" // for Episode, Restriction, Track - -namespace cspot { -class AccessKeyFetcher; -class CDNTrackStream; -struct Context; - -class TrackProvider { - public: - TrackProvider(std::shared_ptr ctx); - ~TrackProvider(); - - std::shared_ptr loadFromTrackRef(TrackReference& trackRef); - - private: - std::shared_ptr accessKeyFetcher; - std::shared_ptr ctx; - std::unique_ptr cdnStream; - - Track trackInfo; - Episode episodeInfo; - std::weak_ptr currentTrackReference; - TrackReference trackIdInfo; - - void queryMetadata(); - void onMetadataResponse(MercurySession::Response& res); - bool doRestrictionsApply(Restriction* restrictions, int count); - void fetchFile(const std::vector& fileId, - const std::vector& trackId); - bool canPlayTrack(int index); -}; -} // namespace cspot \ No newline at end of file diff --git a/components/spotify/cspot/include/TrackQueue.h b/components/spotify/cspot/include/TrackQueue.h new file mode 100644 index 00000000..7ff097e3 --- /dev/null +++ b/components/spotify/cspot/include/TrackQueue.h @@ -0,0 +1,134 @@ +#pragma once + +#include // for size_t +#include +#include +#include +#include + +#include "BellTask.h" +#include "PlaybackState.h" +#include "TrackReference.h" + +#include "protobuf/metadata.pb.h" // for Track, _Track, AudioFile, Episode + +namespace bell { +class WrappedSemaphore; +}; + +namespace cspot { +struct Context; +class AccessKeyFetcher; +class CDNAudioFile; + +// Used in got track info event +struct TrackInfo { + std::string name, album, artist, imageUrl, trackId; + uint32_t duration; + + void loadPbTrack(Track* pbTrack, const std::vector& gid); + void loadPbEpisode(Episode* pbEpisode, const std::vector& gid); +}; + +class QueuedTrack { + public: + QueuedTrack(TrackReference& ref, std::shared_ptr ctx, + uint32_t requestedPosition = 0); + ~QueuedTrack(); + + enum class State { + QUEUED, + PENDING_META, + KEY_REQUIRED, + PENDING_KEY, + CDN_REQUIRED, + READY, + FAILED + }; + + std::shared_ptr loadedSemaphore; + + State state = State::QUEUED; // Current state of the track + TrackReference ref; // Holds GID, URI and Context + TrackInfo trackInfo; // Full track information fetched from spotify, name etc + + uint32_t requestedPosition; + std::string identifier; + + // Will return nullptr if the track is not ready + std::shared_ptr getAudioFile(); + + // --- Steps --- + void stepLoadMetadata( + Track* pbTrack, Episode* pbEpisode, std::mutex& trackListMutex, + std::shared_ptr updateSemaphore); + + void stepParseMetadata(Track* pbTrack, Episode* pbEpisode); + + void stepLoadAudioFile( + std::mutex& trackListMutex, + std::shared_ptr updateSemaphore); + + void stepLoadCDNUrl(const std::string& accessKey); + + void expire(); + + private: + std::shared_ptr ctx; + + uint64_t pendingMercuryRequest = 0; + uint32_t pendingAudioKeyRequest = 0; + + std::vector trackId, fileId, audioKey; + std::string cdnUrl; +}; + +class TrackQueue : public bell::Task { + public: + TrackQueue(std::shared_ptr ctx, + std::shared_ptr playbackState); + ~TrackQueue(); + + enum class SkipDirection { NEXT, PREV }; + + std::shared_ptr playableSemaphore; + std::atomic notifyPending = false; + + + void runTask() override; + void stopTask(); + + bool hasTracks(); + bool isFinished(); + bool skipTrack(SkipDirection dir, bool expectNotify = true); + void updateTracks(uint32_t requestedPosition = 0, bool initial = false); + TrackInfo getTrackInfo(std::string_view identifier); + std::shared_ptr consumeTrack( + std::shared_ptr prevSong, int& offset); + + private: + static const int MAX_TRACKS_PRELOAD = 3; + + std::shared_ptr accessKeyFetcher; + std::shared_ptr playbackState; + std::shared_ptr ctx; + std::shared_ptr processSemaphore; + + std::deque> preloadedTracks; + std::vector currentTracks; + std::mutex tracksMutex, runningMutex; + + // PB data + Track pbTrack; + Episode pbEpisode; + + std::string accessKey; + + int16_t currentTracksIndex = -1; + + bool isRunning = false; + + void processTrack(std::shared_ptr track); + bool queueNextTrack(int offset = 0, uint32_t positionMs = 0); +}; +} // namespace cspot diff --git a/components/spotify/cspot/src/CDNTrackStream.cpp b/components/spotify/cspot/src/CDNAudioFile.cpp similarity index 68% rename from components/spotify/cspot/src/CDNTrackStream.cpp rename to components/spotify/cspot/src/CDNAudioFile.cpp index e38c6f13..f45c3237 100644 --- a/components/spotify/cspot/src/CDNTrackStream.cpp +++ b/components/spotify/cspot/src/CDNAudioFile.cpp @@ -1,4 +1,4 @@ -#include "CDNTrackStream.h" +#include "CDNAudioFile.h" #include // for memcpy #include // for __base @@ -7,15 +7,16 @@ #include // for string_view #include // for remove_extent_t -#include "AccessKeyFetcher.h" // for AccessKeyFetcher -#include "BellLogger.h" // for AbstractLogger +#include "AccessKeyFetcher.h" // for AccessKeyFetcher +#include "BellLogger.h" // for AbstractLogger +#include "Crypto.h" #include "Logger.h" // for CSPOT_LOG #include "Packet.h" // for cspot #include "SocketStream.h" // for SocketStream #include "Utils.h" // for bigNumAdd, bytesToHexString, string... #include "WrappedSemaphore.h" // for WrappedSemaphore #ifdef BELL_ONLY_CJSON -#include "cJSON.h" +#include "cJSON.h " #else #include "nlohmann/json.hpp" // for basic_json<>::object_t, basic_json #include "nlohmann/json_fwd.hpp" // for json @@ -23,73 +24,22 @@ using namespace cspot; -CDNTrackStream::CDNTrackStream( - std::shared_ptr accessKeyFetcher) { - this->accessKeyFetcher = accessKeyFetcher; - this->status = Status::INITIALIZING; - this->trackReady = std::make_unique(5); +CDNAudioFile::CDNAudioFile(const std::string& cdnUrl, + const std::vector& audioKey) + : cdnUrl(cdnUrl), audioKey(audioKey) { this->crypto = std::make_unique(); } -CDNTrackStream::~CDNTrackStream() {} - -void CDNTrackStream::fail() { - this->status = Status::FAILED; - this->trackReady->give(); -} - -void CDNTrackStream::fetchFile(const std::vector& trackId, - const std::vector& audioKey) { - this->status = Status::HAS_DATA; - this->trackId = trackId; - this->audioKey = std::vector(audioKey.begin() + 4, audioKey.end()); - - accessKeyFetcher->getAccessKey([this, trackId, audioKey](std::string key) { - CSPOT_LOG(info, "Received access key, fetching CDN URL..."); - - std::string requestUrl = string_format( - "https://api.spotify.com/v1/storage-resolve/files/audio/interactive/" - "%s?alt=json&product=9", - bytesToHexString(trackId).c_str()); - - auto req = bell::HTTPClient::get( - requestUrl, - {bell::HTTPClient::ValueHeader({"Authorization", "Bearer " + key})}); - - std::string_view result = req->body(); - -#ifdef BELL_ONLY_CJSON - cJSON* jsonResult = cJSON_Parse(result.data()); - std::string cdnUrl = - cJSON_GetArrayItem(cJSON_GetObjectItem(jsonResult, "cdnurl"), 0) - ->valuestring; - cJSON_Delete(jsonResult); -#else - auto jsonResult = nlohmann::json::parse(result); - std::string cdnUrl = jsonResult["cdnurl"][0]; -#endif - if (this->status != Status::FAILED) { - - this->cdnUrl = cdnUrl; - this->status = Status::HAS_URL; - CSPOT_LOG(info, "Received CDN URL, %s", cdnUrl.c_str()); - - this->openStream(); - this->trackReady->give(); - } - }); -} - -size_t CDNTrackStream::getPosition() { +size_t CDNAudioFile::getPosition() { return this->position; } -void CDNTrackStream::seek(size_t newPos) { +void CDNAudioFile::seek(size_t newPos) { this->enableRequestMargin = true; this->position = newPos; } -void CDNTrackStream::openStream() { +void CDNAudioFile::openStream() { CSPOT_LOG(info, "Opening HTTP stream to %s", this->cdnUrl.c_str()); // Open connection, read first 128 bytes @@ -121,10 +71,9 @@ void CDNTrackStream::openStream() { this->position = 0; this->lastRequestPosition = 0; this->lastRequestCapacity = 0; - this->isConnected = true; } -size_t CDNTrackStream::readBytes(uint8_t* dst, size_t bytes) { +size_t CDNAudioFile::readBytes(uint8_t* dst, size_t bytes) { size_t offsetPosition = position + SPOTIFY_OPUS_HEADER; size_t actualFileSize = this->totalFileSize + SPOTIFY_OPUS_HEADER; @@ -199,11 +148,11 @@ size_t CDNTrackStream::readBytes(uint8_t* dst, size_t bytes) { return bytes; } -size_t CDNTrackStream::getSize() { +size_t CDNAudioFile::getSize() { return this->totalFileSize; } -void CDNTrackStream::decrypt(uint8_t* dst, size_t nbytes, size_t pos) { +void CDNAudioFile::decrypt(uint8_t* dst, size_t nbytes, size_t pos) { auto calculatedIV = bigNumAdd(audioAESIV, pos / 16); this->crypto->aesCTRXcrypt(this->audioKey, calculatedIV, dst, nbytes); diff --git a/components/spotify/cspot/src/TrackProvider.cpp b/components/spotify/cspot/src/TrackProvider.cpp deleted file mode 100644 index 14b6ffd7..00000000 --- a/components/spotify/cspot/src/TrackProvider.cpp +++ /dev/null @@ -1,245 +0,0 @@ -#include "TrackProvider.h" - -#include // for assert -#include // for strlen -#include // for uint8_t -#include // for __base -#include // for shared_ptr, weak_ptr, make_shared -#include // for string, operator+ -#include // for remove_extent_t - -#include "AccessKeyFetcher.h" // for AccessKeyFetcher -#include "BellLogger.h" // for AbstractLogger -#include "CDNTrackStream.h" // for CDNTrackStream, CDNTrackStream::Tr... -#include "CSpotContext.h" // for Context::ConfigState, Context (ptr... -#include "Logger.h" // for CSPOT_LOG -#include "MercurySession.h" // for MercurySession, MercurySession::Da... -#include "NanoPBHelper.h" // for pbArrayToVector, pbDecode -#include "Packet.h" // for cspot -#include "TrackReference.h" // for TrackReference, TrackReference::Type -#include "Utils.h" // for bytesToHexString, string_format -#include "WrappedSemaphore.h" // for WrappedSemaphore -#include "pb_decode.h" // for pb_release -#include "protobuf/metadata.pb.h" // for Track, _Track, AudioFile, Episode - -using namespace cspot; - -TrackProvider::TrackProvider(std::shared_ptr ctx) { - this->accessKeyFetcher = std::make_shared(ctx); - this->ctx = ctx; - this->cdnStream = - std::make_unique(this->accessKeyFetcher); - - this->trackInfo = {}; -} - -TrackProvider::~TrackProvider() { - pb_release(Track_fields, &trackInfo); - pb_release(Episode_fields, &trackInfo); -} - -std::shared_ptr TrackProvider::loadFromTrackRef( - TrackReference& trackRef) { - auto track = std::make_shared(this->accessKeyFetcher); - this->currentTrackReference = track; - this->trackIdInfo = trackRef; - - queryMetadata(); - return track; -} - -void TrackProvider::queryMetadata() { - std::string requestUrl = string_format( - "hm://metadata/3/%s/%s", - trackIdInfo.type == TrackReference::Type::TRACK ? "track" : "episode", - bytesToHexString(trackIdInfo.gid).c_str()); - CSPOT_LOG(debug, "Requesting track metadata from %s", requestUrl.c_str()); - - auto responseHandler = [this](MercurySession::Response& res) { - this->onMetadataResponse(res); - }; - - // Execute the request - ctx->session->execute(MercurySession::RequestType::GET, requestUrl, - responseHandler); -} - -void TrackProvider::onMetadataResponse(MercurySession::Response& res) { - CSPOT_LOG(debug, "Got track metadata response"); - - int alternativeCount, filesCount = 0; - bool canPlay = false; - AudioFile* selectedFiles; - std::vector trackId, fileId; - - if (trackIdInfo.type == TrackReference::Type::TRACK) { - pb_release(Track_fields, &trackInfo); - assert(res.parts.size() > 0); - pbDecode(trackInfo, Track_fields, res.parts[0]); - CSPOT_LOG(info, "Track name: %s", trackInfo.name); - CSPOT_LOG(info, "Track duration: %d", trackInfo.duration); - - CSPOT_LOG(debug, "trackInfo.restriction.size() = %d", - trackInfo.restriction_count); - - if (doRestrictionsApply(trackInfo.restriction, - trackInfo.restriction_count)) { - // Go through alternatives - for (int x = 0; x < trackInfo.alternative_count; x++) { - if (!doRestrictionsApply(trackInfo.alternative[x].restriction, - trackInfo.alternative[x].restriction_count)) { - selectedFiles = trackInfo.alternative[x].file; - filesCount = trackInfo.alternative[x].file_count; - trackId = pbArrayToVector(trackInfo.alternative[x].gid); - break; - } - } - } else { - selectedFiles = trackInfo.file; - filesCount = trackInfo.file_count; - trackId = pbArrayToVector(trackInfo.gid); - } - - // Set track's metadata - auto trackRef = this->currentTrackReference.lock(); - - auto imageId = - pbArrayToVector(trackInfo.album.cover_group.image[0].file_id); - - trackRef->trackInfo.trackId = bytesToHexString(trackIdInfo.gid); - trackRef->trackInfo.name = std::string(trackInfo.name); - trackRef->trackInfo.album = std::string(trackInfo.album.name); - trackRef->trackInfo.artist = std::string(trackInfo.artist[0].name); - trackRef->trackInfo.imageUrl = - "https://i.scdn.co/image/" + bytesToHexString(imageId); - trackRef->trackInfo.duration = trackInfo.duration; - } else { - pb_release(Episode_fields, &episodeInfo); - assert(res.parts.size() > 0); - pbDecode(episodeInfo, Episode_fields, res.parts[0]); - - CSPOT_LOG(info, "Episode name: %s", episodeInfo.name); - CSPOT_LOG(info, "Episode duration: %d", episodeInfo.duration); - - CSPOT_LOG(debug, "episodeInfo.restriction.size() = %d", - episodeInfo.restriction_count); - if (!doRestrictionsApply(episodeInfo.restriction, - episodeInfo.restriction_count)) { - selectedFiles = episodeInfo.file; - filesCount = episodeInfo.file_count; - trackId = pbArrayToVector(episodeInfo.gid); - } - - auto trackRef = this->currentTrackReference.lock(); - - auto imageId = pbArrayToVector(episodeInfo.covers->image[0].file_id); - - trackRef->trackInfo.trackId = bytesToHexString(trackIdInfo.gid); - trackRef->trackInfo.name = std::string(episodeInfo.name); - trackRef->trackInfo.album = ""; - trackRef->trackInfo.artist = "", - trackRef->trackInfo.imageUrl = - "https://i.scdn.co/image/" + bytesToHexString(imageId); - trackRef->trackInfo.duration = episodeInfo.duration; - } - - for (int x = 0; x < filesCount; x++) { - CSPOT_LOG(debug, "File format: %d", selectedFiles[x].format); - if (selectedFiles[x].format == ctx->config.audioFormat) { - fileId = pbArrayToVector(selectedFiles[x].file_id); - break; // If file found stop searching - } - - // Fallback to OGG Vorbis 96kbps - if (fileId.size() == 0 && - selectedFiles[x].format == AudioFormat_OGG_VORBIS_96) { - fileId = pbArrayToVector(selectedFiles[x].file_id); - } - } - - // No viable files found for playback - if (fileId.size() == 0) { - CSPOT_LOG(info, "File not available for playback"); - // no alternatives for song - if (!this->currentTrackReference.expired()) { - auto trackRef = this->currentTrackReference.lock(); - trackRef->status = CDNTrackStream::Status::FAILED; - trackRef->trackReady->give(); - } - return; - } - - this->fetchFile(fileId, trackId); -} - -void TrackProvider::fetchFile(const std::vector& fileId, - const std::vector& trackId) { - ctx->session->requestAudioKey( - trackId, fileId, - [this, fileId](bool success, const std::vector& audioKey) { - if (success) { - CSPOT_LOG(info, "Got audio key"); - if (!this->currentTrackReference.expired()) { - auto ref = this->currentTrackReference.lock(); - ref->fetchFile(fileId, audioKey); - } - - } else { - CSPOT_LOG(error, "Failed to get audio key"); - if (!this->currentTrackReference.expired()) { - auto ref = this->currentTrackReference.lock(); - ref->fail(); - } - } - }); -} - -bool countryListContains(char* countryList, char* country) { - uint16_t countryList_length = strlen(countryList); - for (int x = 0; x < countryList_length; x += 2) { - if (countryList[x] == country[0] && countryList[x + 1] == country[1]) { - return true; - } - } - return false; -} - -bool TrackProvider::doRestrictionsApply(Restriction* restrictions, int count) { - for (int x = 0; x < count; x++) { - if (restrictions[x].countries_allowed != nullptr) { - return !countryListContains(restrictions[x].countries_allowed, - (char*)ctx->config.countryCode.c_str()); - } - - if (restrictions[x].countries_forbidden != nullptr) { - return countryListContains(restrictions[x].countries_forbidden, - (char*)ctx->config.countryCode.c_str()); - } - } - - return false; -} - -bool TrackProvider::canPlayTrack(int altIndex) { - if (altIndex < 0) { - - } else { - for (int x = 0; x < trackInfo.alternative[altIndex].restriction_count; - x++) { - if (trackInfo.alternative[altIndex].restriction[x].countries_allowed != - nullptr) { - return countryListContains( - trackInfo.alternative[altIndex].restriction[x].countries_allowed, - (char*)ctx->config.countryCode.c_str()); - } - - if (trackInfo.alternative[altIndex].restriction[x].countries_forbidden != - nullptr) { - return !countryListContains( - trackInfo.alternative[altIndex].restriction[x].countries_forbidden, - (char*)ctx->config.countryCode.c_str()); - } - } - } - return true; -} diff --git a/components/spotify/cspot/src/TrackQueue.cpp b/components/spotify/cspot/src/TrackQueue.cpp new file mode 100644 index 00000000..f2e440d6 --- /dev/null +++ b/components/spotify/cspot/src/TrackQueue.cpp @@ -0,0 +1,603 @@ +#include "TrackQueue.h" +#include + +#include +#include +#include +#include + +#include "AccessKeyFetcher.h" +#include "BellTask.h" +#include "CDNAudioFile.h" +#include "CSpotContext.h" +#include "HTTPClient.h" +#include "Logger.h" +#include "Utils.h" +#include "WrappedSemaphore.h" +#ifdef BELL_ONLY_CJSON +#include "cJSON.h" +#else +#include "nlohmann/json.hpp" // for basic_json<>::object_t, basic_json +#include "nlohmann/json_fwd.hpp" // for json +#endif +#include "protobuf/metadata.pb.h" + +using namespace cspot; +namespace TrackDataUtils { +bool countryListContains(char* countryList, const char* country) { + uint16_t countryList_length = strlen(countryList); + for (int x = 0; x < countryList_length; x += 2) { + if (countryList[x] == country[0] && countryList[x + 1] == country[1]) { + return true; + } + } + return false; +} + +bool doRestrictionsApply(Restriction* restrictions, int count, + const char* country) { + for (int x = 0; x < count; x++) { + if (restrictions[x].countries_allowed != nullptr) { + return !countryListContains(restrictions[x].countries_allowed, country); + } + + if (restrictions[x].countries_forbidden != nullptr) { + return countryListContains(restrictions[x].countries_forbidden, country); + } + } + + return false; +} + +bool canPlayTrack(Track& trackInfo, int altIndex, const char* country) { + if (altIndex < 0) { + + } else { + for (int x = 0; x < trackInfo.alternative[altIndex].restriction_count; + x++) { + if (trackInfo.alternative[altIndex].restriction[x].countries_allowed != + nullptr) { + return countryListContains( + trackInfo.alternative[altIndex].restriction[x].countries_allowed, + country); + } + + if (trackInfo.alternative[altIndex].restriction[x].countries_forbidden != + nullptr) { + return !countryListContains( + trackInfo.alternative[altIndex].restriction[x].countries_forbidden, + country); + } + } + } + return true; +} +} // namespace TrackDataUtils + +void TrackInfo::loadPbTrack(Track* pbTrack, const std::vector& gid) { + // Generate ID based on GID + trackId = bytesToHexString(gid); + + name = std::string(pbTrack->name); + + if (pbTrack->artist_count > 0) { + // Handle artist data + artist = std::string(pbTrack->artist[0].name); + } + + if (pbTrack->has_album) { + // Handle album data + album = std::string(pbTrack->album.name); + + if (pbTrack->album.has_cover_group && + pbTrack->album.cover_group.image_count > 0) { + auto imageId = + pbArrayToVector(pbTrack->album.cover_group.image[0].file_id); + imageUrl = "https://i.scdn.co/image/" + bytesToHexString(imageId); + } + } + + duration = pbTrack->duration; +} + +void TrackInfo::loadPbEpisode(Episode* pbEpisode, + const std::vector& gid) { + // Generate ID based on GID + trackId = bytesToHexString(gid); + + name = std::string(pbEpisode->name); + + if (pbEpisode->covers->image_count > 0) { + // Handle episode info + auto imageId = pbArrayToVector(pbEpisode->covers->image[0].file_id); + imageUrl = "https://i.scdn.co/image/" + bytesToHexString(imageId); + } + + duration = pbEpisode->duration; +} + +QueuedTrack::QueuedTrack(TrackReference& ref, + std::shared_ptr ctx, + uint32_t requestedPosition) + : requestedPosition(requestedPosition), ctx(ctx) { + this->ref = ref; + + loadedSemaphore = std::make_shared(); + state = State::QUEUED; +} + +QueuedTrack::~QueuedTrack() { + state = State::FAILED; + loadedSemaphore->give(); + + if (pendingMercuryRequest != 0) { + ctx->session->unregister(pendingMercuryRequest); + } + + if (pendingAudioKeyRequest != 0) { + ctx->session->unregisterAudioKey(pendingAudioKeyRequest); + } +} + +std::shared_ptr QueuedTrack::getAudioFile() { + if (state != State::READY) { + return nullptr; + } + + return std::make_shared(cdnUrl, audioKey); +} + +void QueuedTrack::stepParseMetadata(Track* pbTrack, Episode* pbEpisode) { + int alternativeCount, filesCount = 0; + bool canPlay = false; + AudioFile* selectedFiles = nullptr; + + const char* countryCode = ctx->config.countryCode.c_str(); + + if (ref.type == TrackReference::Type::TRACK) { + CSPOT_LOG(info, "Track name: %s", pbTrack->name); + CSPOT_LOG(info, "Track duration: %d", pbTrack->duration); + + CSPOT_LOG(debug, "trackInfo.restriction.size() = %d", + pbTrack->restriction_count); + + // Check if we can play the track, if not, try alternatives + if (TrackDataUtils::doRestrictionsApply( + pbTrack->restriction, pbTrack->restriction_count, countryCode)) { + // Go through alternatives + for (int x = 0; x < pbTrack->alternative_count; x++) { + if (!TrackDataUtils::doRestrictionsApply( + pbTrack->alternative[x].restriction, + pbTrack->alternative[x].restriction_count, countryCode)) { + selectedFiles = pbTrack->alternative[x].file; + filesCount = pbTrack->alternative[x].file_count; + trackId = pbArrayToVector(pbTrack->alternative[x].gid); + break; + } + } + } else { + // We can play the track + selectedFiles = pbTrack->file; + filesCount = pbTrack->file_count; + trackId = pbArrayToVector(pbTrack->gid); + } + + if (trackId.size() > 0) { + // Load track information + trackInfo.loadPbTrack(pbTrack, trackId); + } + } else { + // Handle episodes + CSPOT_LOG(info, "Episode name: %s", pbEpisode->name); + CSPOT_LOG(info, "Episode duration: %d", pbEpisode->duration); + + CSPOT_LOG(debug, "episodeInfo.restriction.size() = %d", + pbEpisode->restriction_count); + + // Check if we can play the episode + if (!TrackDataUtils::doRestrictionsApply(pbEpisode->restriction, + pbEpisode->restriction_count, + countryCode)) { + selectedFiles = pbEpisode->file; + filesCount = pbEpisode->file_count; + trackId = pbArrayToVector(pbEpisode->gid); + + // Load track information + trackInfo.loadPbEpisode(pbEpisode, trackId); + } + } + + // Find playable file + for (int x = 0; x < filesCount; x++) { + CSPOT_LOG(debug, "File format: %d", selectedFiles[x].format); + if (selectedFiles[x].format == ctx->config.audioFormat) { + fileId = pbArrayToVector(selectedFiles[x].file_id); + break; // If file found stop searching + } + + // Fallback to OGG Vorbis 96kbps + if (fileId.size() == 0 && + selectedFiles[x].format == AudioFormat_OGG_VORBIS_96) { + fileId = pbArrayToVector(selectedFiles[x].file_id); + } + } + + // No viable files found for playback + if (fileId.size() == 0) { + CSPOT_LOG(info, "File not available for playback"); + + // no alternatives for song + state = State::FAILED; + loadedSemaphore->give(); + return; + } + + // Assign track identifier + identifier = bytesToHexString(fileId); + + state = State::KEY_REQUIRED; +} + +void QueuedTrack::stepLoadAudioFile( + std::mutex& trackListMutex, + std::shared_ptr updateSemaphore) { + // Request audio key + this->pendingAudioKeyRequest = ctx->session->requestAudioKey( + trackId, fileId, + [this, &trackListMutex, updateSemaphore]( + bool success, const std::vector& audioKey) { + std::scoped_lock lock(trackListMutex); + + if (success) { + CSPOT_LOG(info, "Got audio key"); + this->audioKey = + std::vector(audioKey.begin() + 4, audioKey.end()); + + state = State::CDN_REQUIRED; + } else { + CSPOT_LOG(error, "Failed to get audio key"); + state = State::FAILED; + loadedSemaphore->give(); + } + updateSemaphore->give(); + }); + + state = State::PENDING_KEY; +} + +void QueuedTrack::stepLoadCDNUrl(const std::string& accessKey) { + if (accessKey.size() == 0) { + // Wait for access key + return; + } + + // Request CDN URL + CSPOT_LOG(info, "Received access key, fetching CDN URL..."); + + try { + + std::string requestUrl = string_format( + "https://api.spotify.com/v1/storage-resolve/files/audio/interactive/" + "%s?alt=json&product=9", + bytesToHexString(fileId).c_str()); + + auto req = bell::HTTPClient::get( + requestUrl, {bell::HTTPClient::ValueHeader( + {"Authorization", "Bearer " + accessKey})}); + + // Wait for response + std::string_view result = req->body(); + +#ifdef BELL_ONLY_CJSON + cJSON* jsonResult = cJSON_Parse(result.data()); + cdnUrl = cJSON_GetArrayItem(cJSON_GetObjectItem(jsonResult, "cdnurl"), 0) + ->valuestring; + cJSON_Delete(jsonResult); +#else + auto jsonResult = nlohmann::json::parse(result); + cdnUrl = jsonResult["cdnurl"][0]; +#endif + + CSPOT_LOG(info, "Received CDN URL, %s", cdnUrl.c_str()); + state = State::READY; + loadedSemaphore->give(); + } catch (...) { + CSPOT_LOG(error, "Cannot fetch CDN URL"); + state = State::FAILED; + loadedSemaphore->give(); + } +} + +void QueuedTrack::expire() { + if (state != State::QUEUED) { + state = State::FAILED; + loadedSemaphore->give(); + } +} + +void QueuedTrack::stepLoadMetadata( + Track* pbTrack, Episode* pbEpisode, std::mutex& trackListMutex, + std::shared_ptr updateSemaphore) { + + // Prepare request ID + std::string requestUrl = string_format( + "hm://metadata/3/%s/%s", + ref.type == TrackReference::Type::TRACK ? "track" : "episode", + bytesToHexString(ref.gid).c_str()); + + auto responseHandler = [this, pbTrack, pbEpisode, &trackListMutex, + updateSemaphore](MercurySession::Response& res) { + std::scoped_lock lock(trackListMutex); + + if (res.parts.size() == 0) { + // Invalid metadata, cannot proceed + state = State::FAILED; + updateSemaphore->give(); + loadedSemaphore->give(); + return; + } + + // Parse the metadata + if (ref.type == TrackReference::Type::TRACK) { + pb_release(Track_fields, pbTrack); + pbDecode(*pbTrack, Track_fields, res.parts[0]); + } else { + pb_release(Episode_fields, pbEpisode); + pbDecode(*pbEpisode, Episode_fields, res.parts[0]); + } + + // Parse received metadata + stepParseMetadata(pbTrack, pbEpisode); + + updateSemaphore->give(); + }; + // Execute the request + pendingMercuryRequest = ctx->session->execute( + MercurySession::RequestType::GET, requestUrl, responseHandler); + + // Set the state to pending + state = State::PENDING_META; +} + +TrackQueue::TrackQueue(std::shared_ptr ctx, + std::shared_ptr state) + : bell::Task("CSpotTrackQueue", 1024 * 32, 2, 1), + playbackState(state), + ctx(ctx) { + accessKeyFetcher = std::make_shared(ctx); + processSemaphore = std::make_shared(); + playableSemaphore = std::make_shared(); + + // Assign encode callback to track list + playbackState->innerFrame.state.track.funcs.encode = + &TrackReference::pbEncodeTrackList; + playbackState->innerFrame.state.track.arg = ¤tTracks; + pbTrack = Track_init_zero; + pbEpisode = Episode_init_zero; + + // Start the task + startTask(); +}; + +TrackQueue::~TrackQueue() { + stopTask(); + + std::scoped_lock lock(tracksMutex); + + pb_release(Track_fields, &pbTrack); + pb_release(Episode_fields, &pbEpisode); +} + +TrackInfo TrackQueue::getTrackInfo(std::string_view identifier) { + for (auto& track : preloadedTracks) { + if (track->identifier == identifier) + return track->trackInfo; + } + return TrackInfo{}; +} + +void TrackQueue::runTask() { + isRunning = true; + + std::scoped_lock lock(runningMutex); + + std::deque> trackQueue; + + while (isRunning) { + processSemaphore->twait(100); + + // Make sure we have the newest access key + accessKey = accessKeyFetcher->getAccessKey(); + + int loadedIndex = currentTracksIndex; + + // No tracks loaded yet + if (loadedIndex < 0) { + continue; + } else { + std::scoped_lock lock(tracksMutex); + + trackQueue = preloadedTracks; + } + + for (auto& track : trackQueue) { + if (track) { + this->processTrack(track); + } + } + } +} + +void TrackQueue::stopTask() { + if (isRunning) { + isRunning = false; + processSemaphore->give(); + std::scoped_lock lock(runningMutex); + } +} + +std::shared_ptr TrackQueue::consumeTrack( + std::shared_ptr prevTrack, int& offset) { + std::scoped_lock lock(tracksMutex); + + if (currentTracksIndex == -1 || currentTracksIndex >= currentTracks.size()) { + return nullptr; + } + + // No previous track, return head + if (prevTrack == nullptr) { + offset = 0; + + return preloadedTracks[0]; + } + + // if (currentTracksIndex + preloadedTracks.size() >= currentTracks.size()) { + // offset = -1; + + // // Last track in queue + // return nullptr; + // } + + auto prevTrackIter = + std::find(preloadedTracks.begin(), preloadedTracks.end(), prevTrack); + + if (prevTrackIter != preloadedTracks.end()) { + // Get offset of next track + offset = prevTrackIter - preloadedTracks.begin() + 1; + } else { + offset = 0; + } + + if (offset >= preloadedTracks.size()) { + // Last track in preloaded queue + return nullptr; + } + + // Return the current track + return preloadedTracks[offset]; +} + +void TrackQueue::processTrack(std::shared_ptr track) { + switch (track->state) { + case QueuedTrack::State::QUEUED: + track->stepLoadMetadata(&pbTrack, &pbEpisode, tracksMutex, + processSemaphore); + break; + case QueuedTrack::State::KEY_REQUIRED: + track->stepLoadAudioFile(tracksMutex, processSemaphore); + break; + case QueuedTrack::State::CDN_REQUIRED: + track->stepLoadCDNUrl(accessKey); + + if (track->state == QueuedTrack::State::READY) { + if (preloadedTracks.size() < MAX_TRACKS_PRELOAD) { + // Queue a new track to preload + queueNextTrack(preloadedTracks.size()); + } + } + break; + default: + // Do not perform any action + break; + } +} + +bool TrackQueue::queueNextTrack(int offset, uint32_t positionMs) { + const int requestedRefIndex = offset + currentTracksIndex; + if (requestedRefIndex < 0 || requestedRefIndex >= currentTracks.size()) { + return false; + } + + if (offset < 0) { + preloadedTracks.push_front(std::make_shared( + currentTracks[requestedRefIndex], ctx, positionMs)); + } else { + preloadedTracks.push_back(std::make_shared( + currentTracks[requestedRefIndex], ctx, positionMs)); + } + + return true; +} + +bool TrackQueue::skipTrack(SkipDirection dir, bool expectNotify) { + bool canSkipNext = currentTracks.size() > currentTracksIndex + 1; + bool canSkipPrev = currentTracksIndex > 0; + + if ((dir == SkipDirection::NEXT && canSkipNext) || + (dir == SkipDirection::PREV && canSkipPrev)) { + std::scoped_lock lock(tracksMutex); + if (dir == SkipDirection::NEXT) { + preloadedTracks.pop_front(); + + if (!queueNextTrack(preloadedTracks.size() + 1)) { + CSPOT_LOG(info, "Failed to queue next track"); + } + + currentTracksIndex++; + } else { + queueNextTrack(-1); + + if (preloadedTracks.size() > MAX_TRACKS_PRELOAD) { + preloadedTracks.pop_back(); + } + + currentTracksIndex--; + } + + // Update frame data + playbackState->innerFrame.state.playing_track_index = currentTracksIndex; + + if (expectNotify) { + // Reset position to zero + notifyPending = true; + } + + return true; + } + + return false; +} + +bool TrackQueue::hasTracks() { + std::scoped_lock lock(tracksMutex); + + return currentTracks.size() > 0; +} + +bool TrackQueue::isFinished() { + std::scoped_lock lock(tracksMutex); + return currentTracksIndex >= currentTracks.size() - 1; +} + +void TrackQueue::updateTracks(uint32_t requestedPosition, bool initial) { + std::scoped_lock lock(tracksMutex); + + if (initial) { + // Clear preloaded tracks + preloadedTracks.clear(); + + // Copy requested track list + currentTracks = playbackState->remoteTracks; + + currentTracksIndex = playbackState->innerFrame.state.playing_track_index; + + if (currentTracksIndex < currentTracks.size()) { + // Push a song on the preloaded queue + queueNextTrack(0, requestedPosition); + } + + // We already updated track meta, mark it + notifyPending = true; + + playableSemaphore->give(); + } else { + // Clear preloaded tracks + preloadedTracks.clear(); + + // Copy requested track list + currentTracks = playbackState->remoteTracks; + + // Push a song on the preloaded queue + queueNextTrack(0, requestedPosition); + } +} diff --git a/components/spotify/cspot/src/TrackReference.cpp b/components/spotify/cspot/src/TrackReference.cpp new file mode 100644 index 00000000..4c399dba --- /dev/null +++ b/components/spotify/cspot/src/TrackReference.cpp @@ -0,0 +1,156 @@ +#include "TrackReference.h" + +#include "NanoPBExtensions.h" +#include "Utils.h" +#include "protobuf/spirc.pb.h" + +using namespace cspot; + +static constexpr auto base62Alphabet = + "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +TrackReference::TrackReference() : type(Type::TRACK) {} + +void TrackReference::decodeURI() { + if (gid.size() == 0) { + // Episode GID is being fetched via base62 encoded URI + auto idString = uri.substr(uri.find_last_of(":") + 1, uri.size()); + gid = {0}; + + std::string_view alphabet(base62Alphabet); + for (int x = 0; x < idString.size(); x++) { + size_t d = alphabet.find(idString[x]); + gid = bigNumMultiply(gid, 62); + gid = bigNumAdd(gid, d); + } + +#if __cplusplus >= 202002L + if (uri.starts_with("episode")) { +#else + if (uri.find("episode") == 0) { +#endif + type = Type::EPISODE; + } + } +} + +bool TrackReference::operator==(const TrackReference& other) const { + return other.gid == gid && other.uri == uri; +} + +bool TrackReference::pbEncodeTrackList(pb_ostream_t* stream, + const pb_field_t* field, + void* const* arg) { + auto trackQueue = *static_cast*>(*arg); + static TrackRef msg = TrackRef_init_zero; + + // Prepare nanopb callbacks + msg.context.funcs.encode = &bell::nanopb::encodeString; + msg.uri.funcs.encode = &bell::nanopb::encodeString; + msg.gid.funcs.encode = &bell::nanopb::encodeVector; + msg.queued.funcs.encode = &bell::nanopb::encodeBoolean; + + for (auto trackRef : trackQueue) { + if (!pb_encode_tag_for_field(stream, field)) { + return false; + } + + msg.gid.arg = &trackRef.gid; + msg.uri.arg = &trackRef.uri; + msg.context.arg = &trackRef.context; + msg.queued.arg = &trackRef.queued; + + if (!pb_encode_submessage(stream, TrackRef_fields, &msg)) { + return false; + } + } + + return true; +} + +bool TrackReference::pbDecodeTrackList(pb_istream_t* stream, + const pb_field_t* field, void** arg) { + auto trackQueue = static_cast*>(*arg); + + // Push a new reference + trackQueue->push_back(TrackReference()); + + auto& track = trackQueue->back(); + + bool eof = false; + pb_wire_type_t wire_type; + pb_istream_t substream; + uint32_t tag; + + while (!eof) { + if (!pb_decode_tag(stream, &wire_type, &tag, &eof)) { + // Decoding failed and not eof + if (!eof) { + return false; + } + // EOF + } else { + switch (tag) { + case TrackRef_uri_tag: + case TrackRef_context_tag: + case TrackRef_gid_tag: { + // Make substream + if (!pb_make_string_substream(stream, &substream)) { + + return false; + } + + uint8_t* destBuffer = nullptr; + + // Handle GID + if (tag == TrackRef_gid_tag) { + track.gid.resize(substream.bytes_left); + destBuffer = &track.gid[0]; + } else if (tag == TrackRef_context_tag) { + track.context.resize(substream.bytes_left); + + destBuffer = reinterpret_cast(&track.context[0]); + } else if (tag == TrackRef_uri_tag) { + track.uri.resize(substream.bytes_left); + + destBuffer = reinterpret_cast(&track.uri[0]); + } + + if (!pb_read(&substream, destBuffer, substream.bytes_left)) { + return false; + } + + // Close substream + if (!pb_close_string_substream(stream, &substream)) { + return false; + } + + break; + } + case TrackRef_queued_tag: { + uint32_t queuedValue; + + // Decode boolean + if (!pb_decode_varint32(stream, &queuedValue)) { + return false; + } + + // Cast down to bool + track.queued = (bool)queuedValue; + + break; + } + default: + // Field not known, skip + pb_skip_field(stream, wire_type); + + break; + } + } + } + + // Fill in GID when only URI is provided + track.decodeURI(); + + return true; +}