aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md8
-rw-r--r--images/spotify_logo.pngbin0 -> 10877 bytes
-rw-r--r--plugins/Spotify.hpp46
-rw-r--r--src/QuickMedia.cpp11
-rw-r--r--src/plugins/Spotify.cpp281
5 files changed, 340 insertions, 6 deletions
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 <plugin> [--tor] [--use-system-mpv-config] [--dir <directory>]
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
--- /dev/null
+++ b/images/spotify_logo.png
Binary files 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<CommandArg> 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<Tab> &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<Tab> &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<RelatedVideosPage> create_related_videos_page(Program *, const std::string &, const std::string &) override { return nullptr; }
+ std::unique_ptr<LazyFetchPage> 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 <plugin> [--tor] [--no-video] [--use-system-mpv-config] [--dir <directory>]\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<PornhubSearchPage>(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<SpotifyPodcastSearchPage>(this), create_search_bar("Search...", 250)});
} else if(strcmp(plugin_name, "mastodon") == 0 || strcmp(plugin_name, "pleroma") == 0) {
auto pleroma = std::make_shared<Pleroma>();
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<CommandArg> 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<CommandArg> 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<CommandArg> 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<Tab> &result_tabs) {
+ auto body = create_body();
+ auto episode_list_page = std::make_unique<SpotifyEpisodeListPage>(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<CommandArg> 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<Tab> &result_tabs) {
+ result_tabs.push_back(Tab{nullptr, std::make_unique<SpotifyAudioPage>(program), nullptr});
+ return PluginResult::OK;
+ }
+} \ No newline at end of file