1 #include "lib/spotify/api.hpp"
2 
3 using namespace lib::spt;
4 
api(lib::settings & settings,const lib::http_client & http_client)5 api::api(lib::settings &settings, const lib::http_client &http_client)
6 	: settings(settings),
7 	http(http_client)
8 {
9 }
10 
refresh(bool force)11 void api::refresh(bool force)
12 {
13 	constexpr long s_in_hour = 60 * 60;
14 
15 	if (!force
16 		&& lib::date_time::seconds_since_epoch() - settings.account.last_refresh < s_in_hour)
17 	{
18 		lib::log::dev("Last refresh was less than an hour ago, not refreshing access token");
19 		last_auth = settings.account.last_refresh;
20 		return;
21 	}
22 
23 	// Make sure we have a refresh token
24 	auto refresh_token = settings.account.refresh_token;
25 	if (refresh_token.empty())
26 	{
27 		throw lib::spt::error("No refresh token", "token");
28 	}
29 
30 	// Create form
31 	auto post_data = lib::fmt::format("grant_type=refresh_token&refresh_token={}",
32 		refresh_token);
33 
34 	// Create request
35 	auto auth_header = lib::fmt::format("Basic {}",
36 		lib::base64::encode(lib::fmt::format("{}:{}",
37 			settings.account.client_id, settings.account.client_secret)));
38 
39 	// Send request
40 	auto reply = request_refresh(post_data, auth_header);
41 	if (reply.empty())
42 	{
43 		throw lib::spt::error("No response", "token");
44 	}
45 
46 	// Parse as JSON
47 	const auto json = nlohmann::json::parse(reply);
48 
49 	// Check if error
50 	if (json.contains("error_description") || !json.contains("access_token"))
51 	{
52 		auto error = json.at("error_description").get<std::string>();
53 		throw lib::spt::error(error.empty()
54 			? "No access token" : error, "token");
55 	}
56 
57 	// Save as access token
58 	last_auth = lib::date_time::seconds_since_epoch();
59 	settings.account.last_refresh = last_auth;
60 	settings.account.access_token = json.at("access_token").get<std::string>();
61 	settings.save();
62 }
63 
auth_headers()64 auto api::auth_headers() -> lib::headers
65 {
66 	constexpr int secsInHour = 3600;
67 
68 	// See when last refresh was
69 	auto last_refresh = lib::date_time::seconds_since_epoch() - last_auth;
70 	if (last_refresh >= secsInHour)
71 	{
72 		lib::log::dev("Access token probably expired, refreshing");
73 		try
74 		{
75 			refresh();
76 		}
77 		catch (const std::exception &e)
78 		{
79 			lib::log::error("Refresh failed: {}", e.what());
80 		}
81 	}
82 
83 	return {
84 		{
85 			"Authorization",
86 			lib::fmt::format("Bearer {}", settings.account.access_token),
87 		},
88 	};
89 }
90 
parse_json(const std::string & url,const std::string & data)91 auto api::parse_json(const std::string &url, const std::string &data) -> nlohmann::json
92 {
93 	// No data, no response, no error
94 	if (data.empty())
95 	{
96 		return nlohmann::json();
97 	}
98 
99 	auto json = nlohmann::json::parse(data);
100 
101 	if (!lib::spt::error::is(json))
102 	{
103 		return json;
104 	}
105 
106 	auto err = lib::spt::error::error_message(json);
107 	lib::log::error("{} failed: {}", url, err);
108 	throw lib::spt::error(err, url);
109 }
110 
request_refresh(const std::string & post_data,const std::string & authorization)111 auto api::request_refresh(const std::string &post_data,
112 	const std::string &authorization) -> std::string
113 {
114 	return http.post("https://accounts.spotify.com/api/token", {
115 		{"Content-Type", "application/x-www-form-urlencoded"},
116 		{"Authorization", authorization},
117 	}, post_data);
118 }
119 
error_message(const std::string & url,const std::string & data)120 auto api::error_message(const std::string &url, const std::string &data) -> std::string
121 {
122 	nlohmann::json json;
123 	try
124 	{
125 		if (!data.empty())
126 		{
127 			json = nlohmann::json::parse(data);
128 		}
129 	}
130 	catch (const std::exception &e)
131 	{
132 		lib::log::warn("{} failed: {}", url, e.what());
133 		return std::string();
134 	}
135 
136 	if (json.is_null() || !json.is_object() || !json.contains("error"))
137 	{
138 		return std::string();
139 	}
140 
141 	auto message = json.at("error").at("message").get<std::string>();
142 	if (!message.empty())
143 	{
144 		lib::log::error("{} failed: {}", url, message);
145 	}
146 	return message;
147 }
148 
select_device(const std::vector<lib::spt::device> &,lib::callback<lib::spt::device> & callback)149 void api::select_device(const std::vector<lib::spt::device> &/*devices*/,
150 	lib::callback<lib::spt::device> &callback)
151 {
152 	callback(lib::spt::device());
153 }
154 
to_uri(const std::string & type,const std::string & id)155 auto api::to_uri(const std::string &type, const std::string &id) -> std::string
156 {
157 	return lib::strings::starts_with(id, "spotify:")
158 		? id
159 		: lib::fmt::format("spotify:{}:{}", type, id);
160 }
161 
to_id(const std::string & id)162 auto api::to_id(const std::string &id) -> std::string
163 {
164 	auto i = lib::strings::last_index_of(id, ":");
165 	return i >= 0
166 		? id.substr(i + 1)
167 		: id;
168 }
169 
to_full_url(const std::string & relative_url)170 auto api::to_full_url(const std::string &relative_url) -> std::string
171 {
172 	return lib::fmt::format("https://api.spotify.com/v1/{}", relative_url);
173 }
174 
follow_type_string(lib::follow_type type)175 auto api::follow_type_string(lib::follow_type type) -> std::string
176 {
177 	switch (type)
178 	{
179 		case lib::follow_type::artist:
180 			return "artist";
181 
182 		case lib::follow_type::user:
183 			return "user";
184 	}
185 
186 	return std::string();
187 }
188 
set_current_device(const std::string & id)189 void api::set_current_device(const std::string &id)
190 {
191 	settings.general.last_device = id;
192 	settings.save();
193 }
194 
get_current_device() const195 auto api::get_current_device() const -> const std::string &
196 {
197 	return settings.general.last_device;
198 }
199 
200 //region GET
201 
get(const std::string & url,lib::callback<nlohmann::json> & callback)202 void api::get(const std::string &url, lib::callback<nlohmann::json> &callback)
203 {
204 	http.get(to_full_url(url), auth_headers(),
205 		[url, callback](const std::string &response)
206 		{
207 			try
208 			{
209 				callback(response.empty()
210 					? nlohmann::json()
211 					: nlohmann::json::parse(response));
212 			}
213 			catch (const std::exception &e)
214 			{
215 				lib::log::error("{} failed: {}", url, e.what());
216 			}
217 		});
218 }
219 
get_items(const std::string & url,const std::string & key,lib::callback<nlohmann::json> & callback)220 void api::get_items(const std::string &url, const std::string &key,
221 	lib::callback<nlohmann::json> &callback)
222 {
223 	constexpr size_t api_prefix_length = 27;
224 
225 	auto api_url = lib::strings::starts_with(url, "https://api.spotify.com/v1/")
226 		? url.substr(api_prefix_length)
227 		: url;
228 
229 	get(api_url, [this, key, callback](const nlohmann::json &json)
230 	{
231 		if (!key.empty() && !json.contains(key))
232 		{
233 			lib::log::error(R"(no such key "{}" in "{}" ({}))", key, json.dump());
234 		}
235 
236 		const auto &items = (key.empty() ? json : json.at(key)).at("items");
237 		if (json.contains("next") && json.at("next").is_string())
238 		{
239 			const auto &next = json.at("next").get<std::string>();
240 			get_items(next, key, [items, callback](const nlohmann::json &next)
241 			{
242 				callback(lib::json::combine(items, next));
243 			});
244 			return;
245 		}
246 		callback(items);
247 	});
248 }
249 
get_items(const std::string & url,lib::callback<nlohmann::json> & callback)250 void api::get_items(const std::string &url, lib::callback<nlohmann::json> &callback)
251 {
252 	get_items(url, std::string(), callback);
253 }
254 
255 //endregion
256 
257 //region PUT
258 
put(const std::string & url,const nlohmann::json & body,lib::callback<std::string> & callback)259 void api::put(const std::string &url, const nlohmann::json &body,
260 	lib::callback<std::string> &callback)
261 {
262 	auto header = auth_headers();
263 	header["Content-Type"] = "application/json";
264 
265 	auto data = body.is_null()
266 		? std::string()
267 		: body.dump();
268 
269 	http.put(to_full_url(url), data, header,
270 		[this, url, body, callback](const std::string &response)
271 		{
272 			auto error = error_message(url, response);
273 
274 			if (lib::strings::contains(error, "No active device found")
275 				|| lib::strings::contains(error, "Device not found"))
276 			{
277 				devices([this, url, body, error, callback]
278 					(const std::vector<lib::spt::device> &devices)
279 				{
280 					if (devices.empty())
281 					{
282 						if (callback)
283 						{
284 							callback(error);
285 						}
286 					}
287 					else
288 					{
289 						this->select_device(devices, [this, url, body, callback, error]
290 							(const lib::spt::device &device)
291 						{
292 							if (device.id.empty())
293 							{
294 								callback(error);
295 								return;
296 							}
297 
298 							// Remember old device to replace in new URL
299 							const auto &old_device = settings.general.last_device;
300 
301 							this->set_device(device, [this, url, body, callback, device, old_device]
302 								(const std::string &status)
303 							{
304 								if (status.empty())
305 								{
306 									this->put(lib::strings::replace_all(url, old_device, device.id),
307 										body, callback);
308 								}
309 							});
310 						});
311 					}
312 				});
313 			}
314 			else if (callback)
315 			{
316 				callback(error);
317 			}
318 		});
319 }
320 
put(const std::string & url,lib::callback<std::string> & callback)321 void api::put(const std::string &url, lib::callback<std::string> &callback)
322 {
323 	put(url, nlohmann::json(), callback);
324 }
325 
326 //endregion
327 
328 //region POST
329 
post(const std::string & url,lib::callback<std::string> & callback)330 void api::post(const std::string &url, lib::callback<std::string> &callback)
331 {
332 	auto headers = auth_headers();
333 	headers["Content-Type"] = "application/x-www-form-urlencoded";
334 
335 	http.post(to_full_url(url), headers, [url, callback](const std::string &response)
336 	{
337 		callback(error_message(url, response));
338 	});
339 }
340 
341 //endregion
342 
343 //region DELETE
344 
del(const std::string & url,const nlohmann::json & json,lib::callback<std::string> & callback)345 void api::del(const std::string &url, const nlohmann::json &json,
346 	lib::callback<std::string> &callback)
347 {
348 	auto headers = auth_headers();
349 	headers["Content-Type"] = "application/json";
350 
351 	auto data = json.is_null()
352 		? std::string()
353 		: json.dump();
354 
355 	http.del(to_full_url(url), data, headers,
356 		[url, callback](const std::string &response)
357 		{
358 			callback(error_message(url, response));
359 		});
360 }
361 
del(const std::string & url,lib::callback<std::string> & callback)362 void api::del(const std::string &url, lib::callback<std::string> &callback)
363 {
364 	del(url, nlohmann::json(), callback);
365 }
366 
367 //endregion
368