From aab8d467dbbe664e3f826dbcf08475839759aef9 Mon Sep 17 00:00:00 2001 From: dec05eba Date: Mon, 15 Feb 2021 02:39:31 +0100 Subject: Add spotify podcasts --- README.md | 8 +- images/spotify_logo.png | Bin 0 -> 10877 bytes plugins/Spotify.hpp | 46 ++++++++ src/QuickMedia.cpp | 11 +- src/plugins/Spotify.cpp | 281 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 images/spotify_logo.png create mode 100644 plugins/Spotify.hpp create mode 100644 src/plugins/Spotify.cpp diff --git a/README.md b/README.md index c69ad3a..e4f25d5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # QuickMedia A dmenu-inspired native client for web services. -Currently supported web services: `youtube`, `nyaa.si`, `manganelo`, `mangatown`, `mangadex`, `4chan`, `matrix` and _others_.\ +Currently supported web services: `youtube`, `spotify (podcasts only)`, `nyaa.si`, `manganelo`, `mangatown`, `mangadex`, `4chan`, `matrix` and _others_.\ **Note:** Manganelo doesn't work when used with TOR.\ **Note:** Posting comments on 4chan doesn't work when used with TOR. However browsing works.\ **Note:** TOR system service needs to be running (`systemctl start tor.service`) when using `--tor` option.\ @@ -11,7 +11,7 @@ Cache is stored under `$HOME/.cache/quickmedia`. ``` usage: QuickMedia [--tor] [--use-system-mpv-config] [--dir ] OPTIONS: - plugin The plugin to use. Should be either 4chan, manganelo, mangatown, mangadex, youtube, nyaa.si, matrix, file-manager or pipe + plugin The plugin to use. Should be either 4chan, manganelo, mangatown, mangadex, youtube, spotify, nyaa.si, matrix, file-manager or pipe --no-video Only play audio when playing a video. Disabled by default --tor Use tor. Disabled by default --use-system-mpv-config Use system mpv config instead of no config. Disabled by default @@ -23,7 +23,7 @@ QuickMedia manganelo QuickMedia youtube --tor ``` ## Installation -If you are running arch linux then you can install QuickMedia from aur (https://aur.archlinux.org/packages/quickmedia-git/), otherwise you will need to use [sibs](https://git.dec05eba.com/sibs/) to build QuickMedia manually.\ +If you are running arch linux then you can install QuickMedia from aur (https://aur.archlinux.org/packages/quickmedia-git/), otherwise you will need to use [sibs](https://git.dec05eba.com/sibs/) to build QuickMedia manually. ## Controls Press `Arrow up` / `Arrow down` or `Ctrl+K` / `Ctrl+J` to navigate the menu and also to scroll to the previous/next image when viewing manga in scroll mode. Alternatively you can use the mouse scroll to scroll to the previous/next manga in scroll mode.\ Press `Arrow left` / `Arrow right` or `Ctrl+H` / `Ctrl+L` to switch tab.\ @@ -82,7 +82,7 @@ See project.conf \[dependencies]. `noto-fonts` and `noto-fonts-cjk` is required for latin and japanese characters. ### Optional `mpv` needs to be installed to play videos.\ -`youtube-dl` needs to be installed to play youtube videos.\ +`youtube-dl` needs to be installed to play youtube music/video or spotify podcasts.\ `notify-send` needs to be installed to show notifications (on Linux and other systems that uses d-bus notification system).\ `torsocks` needs to be installed when using the `--tor` option.\ [automedia](https://git.dec05eba.com/AutoMedia/) needs to be installed when tracking manga with `Ctrl + T`.\ diff --git a/images/spotify_logo.png b/images/spotify_logo.png new file mode 100644 index 0000000..a389f79 Binary files /dev/null and b/images/spotify_logo.png differ diff --git a/plugins/Spotify.hpp b/plugins/Spotify.hpp new file mode 100644 index 0000000..9cdd2af --- /dev/null +++ b/plugins/Spotify.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include "Page.hpp" + +namespace QuickMedia { + class SpotifyPage : public Page { + public: + SpotifyPage(Program *program); + virtual ~SpotifyPage() = default; + protected: + DownloadResult download_json_error_retry(Json::Value &json_root, const std::string &url, std::vector additional_args); + private: + PluginResult update_token(); + private: + std::string access_token; + }; + + class SpotifyPodcastSearchPage : public SpotifyPage { + public: + SpotifyPodcastSearchPage(Program *program) : SpotifyPage(program) {} + const char* get_title() const override { return "Podcasts"; } + bool search_is_filter() override { return false; } + SearchResult search(const std::string &str, BodyItems &result_items) override; + PluginResult get_page(const std::string &str, int page, BodyItems &result_items) override; + PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override; + }; + + class SpotifyEpisodeListPage : public SpotifyPage { + public: + SpotifyEpisodeListPage(Program *program, const std::string &url) : SpotifyPage(program), url(url) {} + const char* get_title() const override { return "Episodes"; } + bool search_is_filter() override { return true; } + PluginResult get_page(const std::string &str, int page, BodyItems &result_items) override; + PluginResult submit(const std::string &title, const std::string &url, std::vector &result_tabs) override; + private: + std::string url; + }; + + class SpotifyAudioPage : public VideoPage { + public: + SpotifyAudioPage(Program *program) : VideoPage(program) {} + const char* get_title() const override { return ""; } + std::unique_ptr create_related_videos_page(Program *, const std::string &, const std::string &) override { return nullptr; } + std::unique_ptr create_channels_page(Program *, const std::string &) override { return nullptr; } + }; +} \ No newline at end of file diff --git a/src/QuickMedia.cpp b/src/QuickMedia.cpp index 6eb81c7..99bbfb9 100644 --- a/src/QuickMedia.cpp +++ b/src/QuickMedia.cpp @@ -8,6 +8,7 @@ #include "../plugins/NyaaSi.hpp" #include "../plugins/Matrix.hpp" #include "../plugins/Pleroma.hpp" +#include "../plugins/Spotify.hpp" #include "../plugins/FileManager.hpp" #include "../plugins/Pipe.hpp" #include "../include/Scale.hpp" @@ -418,7 +419,7 @@ namespace QuickMedia { static void usage() { fprintf(stderr, "usage: QuickMedia [--tor] [--no-video] [--use-system-mpv-config] [--dir ]\n"); fprintf(stderr, "OPTIONS:\n"); - fprintf(stderr, " plugin The plugin to use. Should be either 4chan, manganelo, mangatown, mangadex, pornhub, youtube, nyaa.si, matrix, file-manager or pipe\n"); + fprintf(stderr, " plugin The plugin to use. Should be either 4chan, manganelo, mangatown, mangadex, pornhub, youtube, spotify, nyaa.si, matrix, file-manager or pipe\n"); fprintf(stderr, " --no-video Only play audio when playing a video. Disabled by default\n"); fprintf(stderr, " --tor Use tor. Disabled by default\n"); fprintf(stderr, " --use-system-mpv-config Use system mpv config instead of no config. Disabled by default\n"); @@ -458,6 +459,9 @@ namespace QuickMedia { } else if(strcmp(argv[i], "youtube") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/yt_logo_rgb_dark_small.png"; + } else if(strcmp(argv[i], "spotify") == 0) { + plugin_name = argv[i]; + plugin_logo_path = resources_root + "images/spotify_logo.png"; } else if(strcmp(argv[i], "pornhub") == 0) { plugin_name = argv[i]; plugin_logo_path = resources_root + "images/pornhub_logo.png"; @@ -640,6 +644,9 @@ namespace QuickMedia { } else if(strcmp(plugin_name, "pornhub") == 0) { auto search_body = create_body(); tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 500)}); + } else if(strcmp(plugin_name, "spotify") == 0) { + auto search_body = create_body(); + tabs.push_back(Tab{std::move(search_body), std::make_unique(this), create_search_bar("Search...", 250)}); } else if(strcmp(plugin_name, "mastodon") == 0 || strcmp(plugin_name, "pleroma") == 0) { auto pleroma = std::make_shared(); auto search_body = create_body(); @@ -1456,7 +1463,7 @@ namespace QuickMedia { Atom wm_state_atom = XInternAtom(display, "_NET_WM_STATE", False); Atom wm_state_fullscreen_atom = XInternAtom(display, "_NET_WM_STATE_FULLSCREEN", False); if(wm_state_atom == False || wm_state_fullscreen_atom == False) { - fprintf(stderr, "Failed to fullscreen window. The window manager doesn't support fullscreening windows.\n"); + fprintf(stderr, "Failed to fullscreen the window\n"); return false; } diff --git a/src/plugins/Spotify.cpp b/src/plugins/Spotify.cpp new file mode 100644 index 0000000..3ce640a --- /dev/null +++ b/src/plugins/Spotify.cpp @@ -0,0 +1,281 @@ +#include "../../plugins/Spotify.hpp" +#include "../../include/NetUtils.hpp" +#include "../../include/Scale.hpp" + +namespace QuickMedia { + SpotifyPage::SpotifyPage(Program *program) : Page(program) { + Path spotify_cache_path = get_cache_dir().join("spotify").join("access_token"); + if(file_get_content(spotify_cache_path, access_token) != 0) + access_token.clear(); + } + + DownloadResult SpotifyPage::download_json_error_retry(Json::Value &json_root, const std::string &url, std::vector additional_args) { + if(access_token.empty()) { + PluginResult update_token_res = update_token(); + if(update_token_res != PluginResult::OK) return DownloadResult::ERR; + } + + std::string authorization = "authorization: Bearer " + access_token; + additional_args.push_back({ "-H", authorization.c_str() }); + + std::string err_msg; + DownloadResult result = download_json(json_root, url, additional_args, true, &err_msg); + if(result != DownloadResult::OK) return result; + + if(!json_root.isObject()) + return DownloadResult::ERR; + + const Json::Value &error_json = json_root["error"]; + if(error_json.isObject()) { + const Json::Value &status_json = error_json["status"]; + if(status_json.isInt() && status_json.asInt() == 401) { + fprintf(stderr, "Spotify access token expired, requesting a new token...\n"); + PluginResult update_token_res = update_token(); + if(update_token_res != PluginResult::OK) return DownloadResult::ERR; + + authorization = "authorization: Bearer " + access_token; + additional_args.back().value = authorization.c_str(); + DownloadResult result = download_json(json_root, url, additional_args, true); + if(result != DownloadResult::OK) return result; + + if(!json_root.isObject()) + return DownloadResult::ERR; + } + } + + return DownloadResult::OK; + } + + PluginResult SpotifyPage::update_token() { + std::string url = "https://open.spotify.com/get_access_token?reason=transport&productType=web_player"; + + std::vector additional_args = { + { "-H", "authority: application/json" }, + { "-H", "Referer: open.spotify.com" }, + { "-H", "app-platform: WebPlayer" }, + { "-H", "spotify-app-version: 1.1.53.594.g8178ce9b" } + }; + + Json::Value json_root; + DownloadResult result = download_json(json_root, url, std::move(additional_args), true); + if(result != DownloadResult::OK) return download_result_to_plugin_result(result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &access_token_json = json_root["accessToken"]; + if(!access_token_json.isString()) + return PluginResult::ERR; + + access_token = access_token_json.asString(); + Path spotify_cache_dir = get_cache_dir().join("spotify"); + if(create_directory_recursive(spotify_cache_dir) == 0) + fprintf(stderr, "Failed to create spotify cache directory\n"); + spotify_cache_dir.join("access_token"); + file_overwrite_atomic(spotify_cache_dir, access_token); + return PluginResult::OK; + } + + static void image_sources_set_thumbnail(BodyItem *body_item, const Json::Value &sources_list_json) { + if(!sources_list_json.isArray()) + return; + + for(const Json::Value &source_json : sources_list_json) { + if(!source_json.isObject()) + continue; + + const Json::Value &width_json = source_json["width"]; + const Json::Value &height_json = source_json["height"]; + const Json::Value &url_json = source_json["url"]; + if(!width_json.isInt() || !height_json.isInt() || !url_json.isString()) + continue; + + if(height_json.asInt() >= 200 && height_json.asInt() <= 350) { + body_item->thumbnail_url = url_json.asString(); + body_item->thumbnail_mask_type = ThumbnailMaskType::CIRCLE; + body_item->thumbnail_size.x = width_json.asInt(); + body_item->thumbnail_size.y = height_json.asInt(); + body_item->thumbnail_size = clamp_to_size(body_item->thumbnail_size, sf::Vector2i(150, 150)); + return; + } + } + } + + SearchResult SpotifyPodcastSearchPage::search(const std::string &str, BodyItems &result_items) { + PluginResult result = get_page(str, 0, result_items); + if(result != PluginResult::OK) + return SearchResult::ERR; + return SearchResult::OK; + } + + PluginResult SpotifyPodcastSearchPage::get_page(const std::string &str, int page, BodyItems &result_items) { + std::string url = "https://api.spotify.com/v1/search?query="; + url += url_param_encode(str); + url += "&type=show&include_external=audio&market=US&offset=" + std::to_string(page * 10) + "&limit=10"; + + std::vector additional_args = { + { "-H", "accept: application/json" }, + { "-H", "Referer: https://open.spotify.com/" }, + { "-H", "app-platform: WebPlayer" }, + { "-H", "spotify-app-version: 1.1.53.594.g8178ce9b" } + }; + + Json::Value json_root; + DownloadResult result = download_json_error_retry(json_root, url, additional_args); + if(result != DownloadResult::OK) return download_result_to_plugin_result(result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &shows_json = json_root["shows"]; + if(!shows_json.isObject()) + return PluginResult::ERR; + + const Json::Value &items_json = shows_json["items"]; + if(!items_json.isArray()) + return PluginResult::ERR; + + for(const Json::Value &item_json : items_json) { + if(!item_json.isObject()) + continue; + + const Json::Value &name_json = item_json["name"]; + const Json::Value &uri_json = item_json["uri"]; + if(!name_json.isString() || !uri_json.isString()) + continue; + + auto body_item = BodyItem::create(name_json.asString()); + body_item->url = uri_json.asString(); + + const Json::Value &publisher_json = item_json["publisher"]; + if(publisher_json.isString()) { + body_item->set_description(publisher_json.asString()); + body_item->set_description_color(sf::Color(179, 179, 179)); + } + + image_sources_set_thumbnail(body_item.get(), item_json["images"]); + result_items.push_back(std::move(body_item)); + } + + return PluginResult::OK; + } + + PluginResult SpotifyPodcastSearchPage::submit(const std::string &, const std::string &url, std::vector &result_tabs) { + auto body = create_body(); + auto episode_list_page = std::make_unique(program, url); + PluginResult result = episode_list_page->get_page("", 0, body->items); + if(result != PluginResult::OK) + return result; + + result_tabs.push_back(Tab{std::move(body), std::move(episode_list_page), create_search_bar("Search...", SEARCH_DELAY_FILTER)}); + return result; + } + + PluginResult SpotifyEpisodeListPage::get_page(const std::string &, int page, BodyItems &result_items) { + std::string request_url = "https://api-partner.spotify.com/pathfinder/v1/query?operationName=queryShowEpisodes&variables="; + request_url += url_param_encode("{\"uri\":\"" + url + "\",\"offset\":" + std::to_string(page * 50) + ",\"limit\":50}"); + request_url += "&extensions="; + request_url += url_param_encode("{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"e0e5ce27bd7748d2c59b4d44ba245a8992a05be75d6fabc3b20753fc8857444d\"}}"); + + std::vector additional_args = { + { "-H", "authority: api-partner.spotify.com" }, + { "-H", "accept: application/json" }, + { "-H", "Referer: https://open.spotify.com/" }, + { "-H", "app-platform: WebPlayer" }, + { "-H", "spotify-app-version: 1.1.54.40.g75ab4382" } + }; + + Json::Value json_root; + DownloadResult result = download_json_error_retry(json_root, request_url, additional_args); + if(result != DownloadResult::OK) return download_result_to_plugin_result(result); + + if(!json_root.isObject()) + return PluginResult::ERR; + + const Json::Value &data_json = json_root["data"]; + if(!data_json.isObject()) + return PluginResult::ERR; + + const Json::Value &podcast_json = data_json["podcast"]; + if(!podcast_json.isObject()) + return PluginResult::ERR; + + const Json::Value &episodes_json = podcast_json["episodes"]; + if(!episodes_json.isObject()) + return PluginResult::ERR; + + const Json::Value &episode_items_json = episodes_json["items"]; + if(!episode_items_json.isArray()) + return PluginResult::ERR; + + for(const Json::Value &episode_item_json : episode_items_json) { + if(!episode_item_json.isObject()) + continue; + + const Json::Value &episode_json = episode_item_json["episode"]; + if(!episode_json.isObject()) + continue; + + const Json::Value &name_json = episode_json["name"]; + if(!name_json.isString()) + continue; + + const Json::Value &sharing_info_json = episode_json["sharingInfo"]; + if(!sharing_info_json.isObject()) + continue; + + const Json::Value &share_url_json = sharing_info_json["shareUrl"]; + if(!share_url_json.isString()) + continue; + + auto body_item = BodyItem::create(name_json.asString()); + body_item->url = share_url_json.asString(); + + std::string description; + std::string time; + + const Json::Value &description_json = episode_json["description"]; + if(description_json.isString()) + description += description_json.asString(); + + const Json::Value &release_data_json = episode_json["releaseDate"]; + if(release_data_json.isObject()) { + const Json::Value &iso_string_json = release_data_json["isoString"]; + if(iso_string_json.isString()) + time += iso_string_json.asString(); + } + + const Json::Value &duration_json = episode_json["duration"]; + if(duration_json.isObject()) { + const Json::Value &total_ms_json = duration_json["totalMilliseconds"]; + if(total_ms_json.isInt()) { + if(!time.empty()) + time += " • "; + time += std::to_string(total_ms_json.asInt() / 1000 / 60) + " min"; + } + } + + if(!time.empty()) { + if(!description.empty()) + description += '\n'; + description += std::move(time); + } + + body_item->set_description(std::move(description)); + body_item->set_description_color(sf::Color(179, 179, 179)); + + const Json::Value &cover_art_json = episode_json["coverArt"]; + if(cover_art_json.isObject()) + image_sources_set_thumbnail(body_item.get(), cover_art_json["sources"]); + + result_items.push_back(std::move(body_item)); + } + + return PluginResult::OK; + } + + PluginResult SpotifyEpisodeListPage::submit(const std::string &, const std::string &, std::vector &result_tabs) { + result_tabs.push_back(Tab{nullptr, std::make_unique(program), nullptr}); + return PluginResult::OK; + } +} \ No newline at end of file -- cgit v1.2.3