#include "../../plugins/Soundcloud.hpp" #include "../../include/NetUtils.hpp" #include "../../include/StringUtils.hpp" #include "../../include/Notification.hpp" #include "../../include/Utils.hpp" #include "../../include/Scale.hpp" #include "../../include/Theme.hpp" #include #include namespace QuickMedia { static std::string client_id; // Temporary download workaround using youtube-dl. TODO: Remove this and download .m3u8 files directly class SoundcloudTrack : public BodyItemExtra { public: std::string permalink_url; }; class SoundcloudPlaylist : public BodyItemExtra { public: BodyItems tracks; std::vector tracks_to_load; }; // Return empty string if transcoding files are not found static std::string get_best_transcoding_audio_url(const Json::Value &media_json) { const Json::Value &transcodings_json = media_json["transcodings"]; if(transcodings_json.isArray() && !transcodings_json.empty() && transcodings_json[0].isObject()) { const Json::Value &transcoding_url = transcodings_json[0]["url"]; if(transcoding_url.isString()) return transcoding_url.asString(); } return ""; } static std::string collection_item_get_duration(const Json::Value &item_json) { const Json::Value &full_duration_json = item_json["full_duration"]; if(full_duration_json.isInt64()) return seconds_to_duration(full_duration_json.asInt64() / 1000); const Json::Value &duration_json = item_json["duration"]; if(duration_json.isInt64()) return seconds_to_duration(duration_json.asInt64() / 1000); return ""; } static std::shared_ptr parse_collection_item(const Json::Value &item_json) { std::string title; int num_playlists = 0; const Json::Value &playlist_count_json = item_json["playlist_count"]; if(playlist_count_json.isInt()) num_playlists = playlist_count_json.asInt(); int num_tracks = 0; const Json::Value &track_count_json = item_json["track_count"]; if(track_count_json.isInt()) num_tracks = track_count_json.asInt(); const Json::Value &user_json = item_json["user"]; if(user_json.isObject()) { const Json::Value &username_json = user_json["username"]; if(username_json.isString()) title = username_json.asString(); const Json::Value &track_count_json = user_json["track_count"]; if(track_count_json.isInt()) num_tracks = track_count_json.asInt(); } const Json::Value &title_json = item_json["title"]; const Json::Value &username_json = item_json["username"]; if(title_json.isString()) { if(!title.empty()) title += " - "; title += title_json.asString(); } else if(username_json.isString()) { if(!title.empty()) title += " - "; title += username_json.asString(); } else { return nullptr; } auto body_item = BodyItem::create(std::move(title)); std::string description; const Json::Value &last_modified_json = item_json["last_modified"]; if(last_modified_json.isString()) { const time_t unix_time = iso_utc_to_unix_time(last_modified_json.asCString()); description = "Updated " + unix_time_to_local_time_str(unix_time); } const Json::Value &media_json = item_json["media"]; if(media_json.isObject()) { body_item->url = get_best_transcoding_audio_url(media_json); if(!body_item->url.empty()) { const Json::Value &permalink_url = item_json["permalink_url"]; if(permalink_url.isString()) { auto track = std::make_shared(); track->permalink_url = permalink_url.asString(); body_item->extra = std::move(track); } } } std::string first_track_artwork_url; bool is_playlist = false; if(body_item->url.empty()) { const Json::Value &tracks_json = item_json["tracks"]; if(tracks_json.isArray()) { auto playlist = std::make_shared(); for(const Json::Value &track_json : tracks_json) { if(!track_json.isObject()) continue; auto track = parse_collection_item(track_json); if(track) { if(first_track_artwork_url.empty()) first_track_artwork_url = track->thumbnail_url; playlist->tracks.push_back(std::move(track)); } else { const Json::Value &track_id_json = track_json["id"]; if(track_id_json.isInt64()) playlist->tracks_to_load.push_back(track_id_json.asInt64()); } } num_tracks = tracks_json.size(); if(!description.empty()) description += '\n'; description = "Playlist with " + std::to_string(num_tracks) + " track" + (num_tracks == 1 ? "" : "s"); body_item->extra = std::move(playlist); body_item->url = "track"; is_playlist = true; } } if(body_item->url.empty()) { const Json::Value &id_json = item_json["id"]; if(id_json.isInt64()) body_item->url = "https://api-v2.soundcloud.com/stream/users/" + std::to_string(id_json.asInt64()); } const Json::Value &artwork_url_json = item_json["artwork_url"]; const Json::Value &avatar_url_json = item_json["avatar_url"]; if(artwork_url_json.isString()) { // For larger thumbnails. TODO: Use this when upscaling ui /* if(strstr(artwork_url_json.asCString(), "-large") != 0) { std::string artwork_url = artwork_url_json.asString(); string_replace_all(artwork_url, "-large", "-t200x200"); body_item->thumbnail_url = std::move(artwork_url); body_item->thumbnail_size.x = 200; body_item->thumbnail_size.y = 200; } else { body_item->thumbnail_url = artwork_url_json.asString(); body_item->thumbnail_size.x = 100; body_item->thumbnail_size.y = 100; } */ body_item->thumbnail_url = artwork_url_json.asString(); body_item->thumbnail_size.x = 100; body_item->thumbnail_size.y = 100; } else if(avatar_url_json.isString()) { body_item->thumbnail_url = avatar_url_json.asString(); body_item->thumbnail_size.x = 100; body_item->thumbnail_size.y = 100; body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; } else if(!first_track_artwork_url.empty()) { body_item->thumbnail_url = std::move(first_track_artwork_url); body_item->thumbnail_size.x = 100; body_item->thumbnail_size.y = 100; } else { body_item->thumbnail_size.x = 100; body_item->thumbnail_size.y = 100; } if(username_json.isString()) { if(!description.empty()) description += '\n'; description += "Artist"; if(num_playlists > 0) description += " • " + std::to_string(num_playlists) + " playlist" + (num_playlists == 1 ? "" : "s"); if(num_tracks > 0) description += " • " + std::to_string(num_tracks) + " track" + (num_tracks == 1 ? "" : "s"); } else if(!is_playlist) { if(!description.empty()) description += '\n'; description += "Track"; } std::string duration_str = collection_item_get_duration(item_json); if(!duration_str.empty()) { if(!description.empty()) description += '\n'; description += std::move(duration_str); } body_item->set_description(std::move(description)); body_item->set_description_color(get_theme().faded_text_color); return body_item; } static PluginResult parse_user_page(const Json::Value &json_root, BodyItems &result_items, std::string &next_href) { if(!json_root.isObject()) return PluginResult::ERR; const Json::Value &next_href_json = json_root["next_href"]; if(next_href_json.isString()) next_href = next_href_json.asString(); const Json::Value &collection_json = json_root["collection"]; if(!collection_json.isArray()) return PluginResult::ERR; for(const Json::Value &item_json : collection_json) { if(!item_json.isObject()) continue; const Json::Value &track_json = item_json["track"]; const Json::Value &playlist_json = item_json["playlist"]; if(track_json.isObject()) { auto body_item = parse_collection_item(track_json); if(body_item) result_items.push_back(std::move(body_item)); } else if(playlist_json.isObject()) { auto body_item = parse_collection_item(playlist_json); if(body_item) result_items.push_back(std::move(body_item)); } } return PluginResult::OK; } PluginResult SoundcloudPage::submit(const SubmitArgs &args, std::vector &result_tabs) { if(args.url.empty()) return PluginResult::ERR; if(args.url == "track") { SoundcloudPlaylist *playlist = static_cast(args.extra.get()); auto body = create_body(false, true); body->set_items(playlist->tracks); result_tabs.push_back(Tab{std::move(body), std::make_unique(program, playlist, args.title), nullptr}); } else if(args.url.find("/stream/users/") != std::string::npos) { std::string query_url = args.url + "?client_id=" + client_id + "&limit=20&offset=0&linked_partitioning=1&app_version=1616689516&app_locale=en"; Json::Value json_root; DownloadResult result = download_json(json_root, query_url, {}, true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); auto body = create_body(false, true); std::string next_href; BodyItems body_items; PluginResult pr = parse_user_page(json_root, body_items, next_href); body->set_items(std::move(body_items)); if(pr != PluginResult::OK) return pr; result_tabs.push_back(Tab{std::move(body), std::make_unique(program, args.title, args.url, std::move(next_href)), nullptr}); } else { std::string query_url = args.url + "?client_id=" + client_id; Json::Value json_root; DownloadResult result = download_json(json_root, query_url, {}, true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isObject()) return PluginResult::ERR; const Json::Value &url_json = json_root["url"]; if(!url_json.isString()) return PluginResult::ERR; // TODO: Remove when youtube-dl is no longer required to download music std::string permalink_url; SoundcloudTrack *track = static_cast(args.extra.get()); if(track) permalink_url = track->permalink_url; result_tabs.push_back(Tab{nullptr, std::make_unique(program, args.title, url_json.asString(), std::move(permalink_url)), nullptr}); } return PluginResult::OK; } PluginResult SoundcloudSearchPage::submit(const SubmitArgs &args, std::vector &result_tabs) { return submit_page.submit(args, result_tabs); } SearchResult SoundcloudSearchPage::search(const std::string &str, BodyItems &result_items) { query_urn.clear(); if(str.empty()) return SearchResult::OK; PluginResult result = get_page(str, 0, result_items); if(result != PluginResult::OK) return SearchResult::ERR; return SearchResult::OK; } PluginResult SoundcloudSearchPage::get_page(const std::string &str, int page, BodyItems &result_items) { std::string url = "https://api-v2.soundcloud.com/search?q="; url += url_param_encode(str); url += "&variant_ids=2227&facet=model&client_id=" + client_id + "&limit=20&offset=" + std::to_string(page * 20) + "&linked_partitioning=1&app_version=1616689516&app_locale=en"; if(!query_urn.empty()) url += "&query_url=" + url_param_encode(query_urn); else if(page > 0) return PluginResult::OK; Json::Value json_root; DownloadResult result = download_json(json_root, url, {}, true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isObject()) return PluginResult::ERR; const Json::Value &query_urn_json = json_root["query_urn"]; if(query_urn_json.isString()) query_urn = query_urn_json.asString(); const Json::Value &collection_json = json_root["collection"]; if(!collection_json.isArray()) return PluginResult::ERR; for(const Json::Value &item_json : collection_json) { if(!item_json.isObject()) continue; const Json::Value &kind_json = item_json["kind"]; if(!kind_json.isString()) continue; if(strcmp(kind_json.asCString(), "user") == 0 || strcmp(kind_json.asCString(), "track") == 0 || strcmp(kind_json.asCString(), "playlist") == 0) { auto body_item = parse_collection_item(item_json); if(body_item) result_items.push_back(std::move(body_item)); } } return PluginResult::OK; } PluginResult SoundcloudSearchPage::lazy_fetch(BodyItems&) { if(!client_id.empty()) return PluginResult::OK; std::string website_data; DownloadResult download_result = download_to_string("https://soundcloud.com/", website_data, {}, true); if(download_result != DownloadResult::OK) return download_result_to_plugin_result(download_result); std::vector script_sources; QuickMediaHtmlSearch html_search; int result = quickmedia_html_search_init(&html_search, website_data.c_str(), website_data.size()); if(result != 0) goto cleanup; result = quickmedia_html_find_nodes_xpath(&html_search, "//script", [](QuickMediaMatchNode *node, void *userdata) { std::vector *script_sources = (std::vector*)userdata; QuickMediaStringView src = quickmedia_html_node_get_attribute_value(node, "src"); if(src.data && (memmem(src.data, src.size, "sndcdn.com", 10) || memmem(src.data, src.size, "soundcloud.com", 14))) script_sources->push_back(std::string(src.data, src.size)); return 0; }, &script_sources); cleanup: quickmedia_html_search_deinit(&html_search); if(result != 0) return PluginResult::ERR; async_download_tasks.clear(); for(std::string &script_source : script_sources) { if(string_starts_with(script_source, "//")) script_source = "https://" + script_source.substr(2); else if(string_starts_with(script_source, "/")) script_source = "https://soundcloud.com/" + script_source.substr(1); async_download_tasks.push_back(AsyncTask([script_source]() -> std::string { std::string website_data; DownloadResult download_result = download_to_string(script_source, website_data, {}, true); if(download_result != DownloadResult::OK) return ""; size_t index = website_data.find("client_id="); if(index == std::string::npos) return ""; index += 10; size_t end = website_data.find('&', index); if(end == std::string::npos) end = website_data.size(); return website_data.substr(index, end - index); })); } for(auto &download_task : async_download_tasks) { if(download_task.valid()) { std::string fetched_client_id = download_task.get(); if(client_id.empty() && !fetched_client_id.empty()) client_id = std::move(fetched_client_id); } } if(client_id.empty()) { show_notification("QuickMedia", "Failed to get client id for soundcloud. Try updating QuickMedia or wait until it has been fixed", Urgency::CRITICAL); return PluginResult::ERR; } return PluginResult::OK; } void SoundcloudSearchPage::cancel_operation() { for(auto &download_task : async_download_tasks) { download_task.cancel(); } async_download_tasks.clear(); } PluginResult SoundcloudUserPage::get_page(const std::string&, int page, BodyItems &result_items) { while(current_page < page) { PluginResult plugin_result = get_continuation_page(result_items); if(plugin_result != PluginResult::OK) return plugin_result; ++current_page; } return PluginResult::OK; } PluginResult SoundcloudUserPage::get_continuation_page(BodyItems &result_items) { if(next_href.empty()) return PluginResult::OK; Json::Value json_root; DownloadResult result = download_json(json_root, next_href + "&client_id=" + client_id, {}, true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); return parse_user_page(json_root, result_items, next_href); } PluginResult SoundcloudPlaylistPage::get_page(const std::string&, int, BodyItems &result_items) { std::string ids_param; const size_t tracks_load_end = std::min(track_offset + 10, playlist->tracks_to_load.size()); for(size_t i = track_offset; i < tracks_load_end; ++i) { if(!ids_param.empty()) ids_param += "%2C"; ids_param += std::to_string(playlist->tracks_to_load[i]); } track_offset = tracks_load_end; if(ids_param.empty()) return PluginResult::OK; Json::Value json_root; DownloadResult result = download_json(json_root, "https://api-v2.soundcloud.com/tracks?ids=" + ids_param + "&client_id=" + client_id + "&app_version=1616689516&app_locale=en", {}, true); if(result != DownloadResult::OK) return download_result_to_plugin_result(result); if(!json_root.isArray()) return PluginResult::ERR; for(const Json::Value &item_json : json_root) { if(!item_json.isObject()) continue; auto track_item = parse_collection_item(item_json); if(track_item) result_items.push_back(std::move(track_item)); } return PluginResult::OK; } PluginResult SoundcloudAudioPage::load(std::string &title, std::string&, std::vector&, std::string&) { title = this->title; return PluginResult::OK; } std::string SoundcloudAudioPage::url_get_playable_url(const std::string &url) { std::string query_url = url + "?client_id=" + client_id; Json::Value json_root; DownloadResult result = download_json(json_root, query_url, {}, true); if(result != DownloadResult::OK) return url; if(!json_root.isObject()) return url; const Json::Value &url_json = json_root["url"]; if(!url_json.isString()) return url; return url_json.asString(); } std::string SoundcloudAudioPage::get_download_url(int) { return permalink_url; } }