1 /++
2     Bits and bobs to get Spotify API credentials for playlist management.
3 
4     See_Also:
5         [kameloso.plugins.twitch.base],
6         [kameloso.plugins.twitch.keygen],
7         [kameloso.plugins.twitch.api],
8         [kameloso.plugins.common.core],
9         [kameloso.plugins.common.misc]
10 
11     Copyright: [JR](https://github.com/zorael)
12     License: [Boost Software License 1.0](https://www.boost.org/users/license.html)
13 
14     Authors:
15         [JR](https://github.com/zorael)
16  +/
17 module kameloso.plugins.twitch.spotify;
18 
19 version(TwitchSupport):
20 version(WithTwitchPlugin):
21 
22 private:
23 
24 import kameloso.plugins.twitch.base;
25 import kameloso.plugins.twitch.common;
26 
27 import kameloso.common : logger;
28 import arsd.http2 : HttpClient;
29 import std.json : JSONValue;
30 import std.typecons : Flag, No, Yes;
31 import core.thread : Fiber;
32 
33 
34 // requestSpotifyKeys
35 /++
36     Requests a Spotify API authorisation code from Spotify servers, then uses it
37     to obtain an access key and a refresh OAuth key.
38 
39     Params:
40         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
41 
42     Throws:
43         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
44         on unexpected JSON.
45 
46         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
47         if the returned JSON has an `"error"` field.
48  +/
49 package void requestSpotifyKeys(TwitchPlugin plugin)
50 {
51     import kameloso.logger : LogLevel;
52     import kameloso.terminal.colours.tags : expandTags;
53     import lu.string : contains, nom, stripped;
54     import std.format : format;
55     import std.process : Pid, ProcessException, wait;
56     import std.stdio : File, readln, stdin, stdout, write, writeln;
57 
58     scope(exit) if (plugin.state.settings.flush) stdout.flush();
59 
60     logger.trace();
61     logger.info("== Spotify authorisation key generation mode ==");
62     enum message = "
63 To access the Spotify API you need a <i>client ID</> and a <i>client secret</>.
64 
65 <l>Go here to create a project and generate said credentials:</>
66 
67     <i>https://developer.spotify.com/dashboard</>
68 
69 Make sure to go into <l>Edit Settings</> and add <i>http://localhost</> as a
70 redirect URI. (You need to press the <i>Add</> button for it to save.)
71 Additionally, add your user under <l>Users and Access</>.
72 
73 You also need to supply a channel for which it all relates.
74 (Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign.)
75 
76 Lastly you need a <i>playlist ID</> for song requests to work.
77 A normal URL to any playlist you can modify will work fine.
78 ";
79     writeln(message.expandTags(LogLevel.off));
80 
81     Credentials creds;
82 
83     string channel;
84     while (!channel.length)
85     {
86         immutable rawChannel = readNamedString("<l>Enter your <i>#channel<l>:</> ",
87             0L, *plugin.state.abort);
88         if (*plugin.state.abort) return;
89 
90         channel = rawChannel.stripped;
91 
92         if (!channel.length || channel[0] != '#')
93         {
94             enum channelMessage = "Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign.";
95             logger.warning(channelMessage);
96             channel = string.init;
97         }
98     }
99 
100     creds.spotifyClientID = readNamedString("<l>Copy and paste your <i>OAuth client ID<l>:</> ",
101         32L, *plugin.state.abort);
102     if (*plugin.state.abort) return;
103 
104     creds.spotifyClientSecret = readNamedString("<l>Copy and paste your <i>OAuth client secret<l>:</> ",
105         32L, *plugin.state.abort);
106     if (*plugin.state.abort) return;
107 
108     while (!creds.spotifyPlaylistID.length)
109     {
110         enum playlistIDLength = 22;
111 
112         immutable playlistURL = readNamedString("<l>Copy and paste your <i>playlist URL<l>:</> ",
113             0L, *plugin.state.abort);
114         if (*plugin.state.abort) return;
115 
116         if (playlistURL.length == playlistIDLength)
117         {
118             // Likely a playlist ID
119             creds.spotifyPlaylistID = playlistURL;
120         }
121         else if (playlistURL.contains("spotify.com/playlist/"))
122         {
123             string slice = playlistURL;  // mutable
124             slice.nom("spotify.com/playlist/");
125             creds.spotifyPlaylistID = slice.nom!(Yes.inherit)('?');
126         }
127         else
128         {
129             writeln();
130             enum invalidMessage = "Cannot recognise link as a Spotify playlist URL. " ~
131                 "Try copying again or file a bug.";
132             logger.error(invalidMessage);
133             writeln();
134             continue;
135         }
136     }
137 
138     enum attemptToOpenMessage = `
139 --------------------------------------------------------------------------------
140 
141 <l>Attempting to open the Spotify redirect page in your default web browser.</>
142 
143 <l>Paste the address of the empty page that was opened here.</>
144 
145 * The redirected address should start with <i>http://localhost</>.
146 * It will probably say "<l>this site can't be reached</>" or "<l>unable to connect</>".
147 * If you are running local web server on port <i>80</>, you may have to temporarily
148   disable it for this to work.
149 `;
150     writeln(attemptToOpenMessage.expandTags(LogLevel.off));
151     if (plugin.state.settings.flush) stdout.flush();
152 
153     enum authNode = "https://accounts.spotify.com/authorize";
154     enum urlPattern = authNode ~
155         "?client_id=%s" ~
156         "&client_secret=%s" ~
157         "&redirect_uri=http://localhost" ~
158         "&response_type=code" ~
159         "&scope=playlist-modify-private playlist-modify-public";
160     immutable url = urlPattern.format(creds.spotifyClientID, creds.spotifyClientSecret);
161 
162     Pid browser;
163     scope(exit) if (browser !is null) wait(browser);
164 
165     if (plugin.state.settings.force)
166     {
167         logger.warning("Forcing; not automatically opening browser.");
168         printManualURL(url);
169         if (plugin.state.settings.flush) stdout.flush();
170     }
171     else
172     {
173         try
174         {
175             import kameloso.platform : openInBrowser;
176             browser = openInBrowser(url);
177         }
178         catch (ProcessException _)
179         {
180             // Probably we got some platform wrong and command was not found
181             logger.warning("Error: could not automatically open browser.");
182             printManualURL(url);
183             if (plugin.state.settings.flush) stdout.flush();
184         }
185         catch (Exception _)
186         {
187             logger.warning("Error: no graphical environment detected");
188             printManualURL(url);
189             if (plugin.state.settings.flush) stdout.flush();
190         }
191     }
192 
193     string code;
194 
195     while (!code.length)
196     {
197         scope(exit) if (plugin.state.settings.flush) stdout.flush();
198 
199         enum pasteMessage = "<l>Paste the address of the page you were redirected to here (empty line exits):</>
200 
201 > ";
202         write(pasteMessage.expandTags(LogLevel.off));
203         stdout.flush();
204 
205         stdin.flush();
206         immutable readCode = readln().stripped;
207 
208         if (*plugin.state.abort || !readCode.length)
209         {
210             writeln();
211             logger.warning("Aborting.");
212             logger.trace();
213             *plugin.state.abort = true;
214             return;
215         }
216 
217         if (!readCode.contains("code="))
218         {
219             import lu.string : beginsWith;
220 
221             writeln();
222 
223             if (readCode.beginsWith(authNode))
224             {
225                 enum wrongPageMessage = "Not that page; the empty page you're " ~
226                     "lead to after clicking <l>Allow</>.";
227                 logger.error(wrongPageMessage);
228             }
229             else
230             {
231                 logger.error("Could not make sense of URL. Try again or file a bug.");
232             }
233 
234             writeln();
235             continue;
236         }
237 
238         string slice = readCode;  // mutable
239         slice.nom("?code=");
240         code = slice;
241 
242         if (!code.length)
243         {
244             writeln();
245             logger.error("Invalid code length. Try copying again or file a bug.");
246             writeln();
247             code = string.init;  // reset it so the while loop repeats
248         }
249     }
250 
251     // All done, fetch
252     auto client = getHTTPClient();
253     getSpotifyTokens(client, creds, code);
254 
255     writeln();
256     logger.info("Validating...");
257 
258     immutable validationJSON = validateSpotifyToken(client, creds);
259     if (*plugin.state.abort) return;
260 
261     scope(failure)
262     {
263         import std.stdio : writeln;
264         writeln(validationJSON.toPrettyString);
265     }
266 
267     if (const errorJSON = "error" in validationJSON)
268     {
269         throw new ErrorJSONException((*errorJSON)["message"].str, *errorJSON);
270     }
271     else if ("display_name" !in validationJSON)
272     {
273         throw new UnexpectedJSONException(
274             "Unexpected JSON response from server",
275             validationJSON);
276     }
277 
278     logger.info("All done!");
279     logger.trace();
280 
281     if (auto storedCreds = channel in plugin.secretsByChannel)
282     {
283         import lu.meld : MeldingStrategy, meldInto;
284         creds.meldInto!(MeldingStrategy.aggressive)(*storedCreds);
285     }
286     else
287     {
288         plugin.secretsByChannel[channel] = creds;
289     }
290 
291     saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile);
292 }
293 
294 
295 // getSpotifyTokens
296 /++
297     Request OAuth API tokens from Spotify.
298 
299     Params:
300         client = [arsd.http2.HttpClient|HttpClient] to use.
301         creds = [Credentials] aggregate.
302         code = Spotify authorization code.
303 
304     Throws:
305         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
306         on unexpected JSON.
307 
308         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
309         if the returned JSON has an `"error"` field.
310  +/
311 void getSpotifyTokens(HttpClient client, ref Credentials creds, const string code)
312 {
313     import arsd.http2 : FormData, HttpVerb, Uri;
314     import std.format : format;
315     import std.json : JSONType, parseJSON;
316     import std.string : indexOf;
317 
318     enum node = "https://accounts.spotify.com/api/token";
319     enum urlPattern = node ~
320         "?code=%s" ~
321         "&grant_type=authorization_code" ~
322         "&redirect_uri=http://localhost";
323     immutable url = urlPattern.format(code);
324 
325     if (!client.authorization.length) client.authorization = getSpotifyBase64Authorization(creds);
326 
327     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
328     {
329         try
330         {
331             auto req = client.request(Uri(url), HttpVerb.POST);
332             req.requestParameters.contentType = "application/x-www-form-urlencoded";
333             auto res = req.waitForCompletion();
334 
335             /*
336             {
337                 "access_token": "[redacted]",
338                 "token_type": "Bearer",
339                 "expires_in": 3600,
340                 "refresh_token": "[redacted]",
341                 "scope": "playlist-modify-private playlist-modify-public"
342             }
343             */
344 
345             const json = parseJSON(res.contentText);
346 
347             if (json.type != JSONType.object)
348             {
349                 throw new UnexpectedJSONException("Wrong JSON type in token request response", json);
350             }
351 
352             if (auto errorJSON = "error" in json)
353             {
354                 throw new ErrorJSONException(errorJSON.str, *errorJSON);
355             }
356 
357             creds.spotifyAccessToken = json["access_token"].str;
358             creds.spotifyRefreshToken = json["refresh_token"].str;
359             return;
360         }
361         catch (Exception e)
362         {
363             // Retry until we reach the retry limit, then rethrow
364             if (i < TwitchPlugin.delegateRetries-1) continue;
365             throw e;
366         }
367     }
368 }
369 
370 
371 // refreshSpotifyToken
372 /++
373     Refreshes the OAuth API token in the passed Spotify credentials.
374 
375     Params:
376         client = [arsd.http2.HttpClient|HttpClient] to use.
377         creds = [Credentials] aggregate.
378 
379     Throws:
380         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
381         on unexpected JSON.
382 
383         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
384         if the returned JSON has an `"error"` field.
385  +/
386 void refreshSpotifyToken(HttpClient client, ref Credentials creds)
387 {
388     import arsd.http2 : HttpVerb, Uri;
389     import std.format : format;
390     import std.json : JSONType, parseJSON;
391 
392     enum node = "https://accounts.spotify.com/api/token";
393     enum urlPattern = node ~
394         "?refresh_token=%s" ~
395         "&grant_type=refresh_token";
396     immutable url = urlPattern.format(creds.spotifyRefreshToken);
397 
398     /*if (!client.authorization.length)*/ client.authorization = getSpotifyBase64Authorization(creds);
399 
400     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
401     {
402         try
403         {
404             auto req = client.request(Uri(url), HttpVerb.POST);
405             req.requestParameters.contentType = "application/x-www-form-urlencoded";
406             auto res = req.waitForCompletion();
407 
408             /*
409             {
410                 "access_token": "[redacted]",
411                 "token_type": "Bearer",
412                 "expires_in": 3600,
413                 "scope": "playlist-modify-private playlist-modify-public"
414             }
415             */
416 
417             const json = parseJSON(res.contentText);
418 
419             if (json.type != JSONType.object)
420             {
421                 throw new UnexpectedJSONException("Wrong JSON type in token refresh response", json);
422             }
423 
424             if (auto errorJSON = "error" in json)
425             {
426                 throw new ErrorJSONException(errorJSON.str, *errorJSON);
427             }
428 
429             creds.spotifyAccessToken = json["access_token"].str;
430             // refreshToken is not present and stays the same as before
431             return;
432         }
433         catch (Exception e)
434         {
435             // Retry until we reach the retry limit, then rethrow
436             if (i < TwitchPlugin.delegateRetries-1) continue;
437             throw e;
438         }
439     }
440 }
441 
442 
443 // getBase64Authorization
444 /++
445     Construts a `Basic` OAuth authorisation string based on the Spotify client ID
446     and client secret.
447 
448     Params:
449         creds = [Credentials] aggregate.
450 
451     Returns:
452         A string to be used as a `Basic` authorisation token.
453  +/
454 auto getSpotifyBase64Authorization(const Credentials creds)
455 {
456     import std.base64 : Base64;
457     import std.conv : text;
458 
459     auto decoded = cast(ubyte[])text(creds.spotifyClientID, ':', creds.spotifyClientSecret);
460     return "Basic " ~ cast(string)Base64.encode(decoded);
461 }
462 
463 
464 // addTrackToSpotifyPlaylist
465 /++
466     Adds a track to the Spotify playlist whose ID is stored in the passed [Credentials].
467 
468     Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber].
469 
470     Params:
471         plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin].
472         creds = [Credentials] aggregate.
473         trackID = Spotify track ID of the track to add.
474         recursing = Whether or not the function is recursing into itself.
475 
476     Returns:
477         A [std.json.JSONValue|JSONValue] of the response.
478 
479     Throws:
480         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
481         on unexpected JSON.
482 
483         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
484         if the returned JSON has an `"error"` field.
485  +/
486 package JSONValue addTrackToSpotifyPlaylist(
487     TwitchPlugin plugin,
488     ref Credentials creds,
489     const string trackID,
490     const Flag!"recursing" recursing = No.recursing)
491 in (Fiber.getThis, "Tried to call `addTrackToSpotifyPlaylist` from outside a Fiber")
492 {
493     import kameloso.plugins.twitch.api : getUniqueNumericalID, waitForQueryResponse;
494     import kameloso.plugins.common.delayawait : delay;
495     import kameloso.thread : ThreadMessage;
496     import arsd.http2 : HttpVerb;
497     import std.algorithm.searching : endsWith;
498     import std.concurrency : prioritySend, send;
499     import std.format : format;
500     import std.json : JSONType, parseJSON;
501     import core.time : msecs;
502 
503     // https://api.spotify.com/v1/playlists/0nqAHNphIb3Qhh5CmD7fg5/tracks?uris=spotify:track:594WPgqPOOy0PqLvScovNO
504 
505     enum urlPattern = "https://api.spotify.com/v1/playlists/%s/tracks?uris=spotify:track:%s";
506     immutable url = urlPattern.format(creds.spotifyPlaylistID, trackID);
507 
508     if (plugin.state.settings.trace)
509     {
510         import kameloso.common : logger;
511         enum pattern = "GET: <i>%s";
512         logger.tracef(pattern, url);
513     }
514 
515     static string authorizationBearer;
516 
517     if (!authorizationBearer.length || !authorizationBearer.endsWith(creds.spotifyAccessToken))
518     {
519         authorizationBearer = "Bearer " ~ creds.spotifyAccessToken;
520     }
521 
522     immutable ubyte[] data;
523     /*immutable*/ int id = getUniqueNumericalID(plugin.bucket);  // Making immutable bumps compilation memory +44mb
524 
525     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
526     {
527         try
528         {
529             plugin.state.mainThread.prioritySend(ThreadMessage.shortenReceiveTimeout());
530 
531             plugin.persistentWorkerTid.send(
532                 id,
533                 url,
534                 authorizationBearer,
535                 HttpVerb.POST,
536                 data,
537                 string.init);
538 
539             static immutable guesstimatePeriodToWaitForCompletion = 300.msecs;
540             delay(plugin, guesstimatePeriodToWaitForCompletion, Yes.yield);
541             immutable response = waitForQueryResponse(plugin, id);
542 
543             /*
544             {
545                 "snapshot_id" : "[redacted]"
546             }
547             */
548             /*
549             {
550                 "error": {
551                     "status": 401,
552                     "message": "The access token expired"
553                 }
554             }
555             */
556 
557             const json = parseJSON(response.str);
558 
559             if (json.type != JSONType.object)
560             {
561                 throw new UnexpectedJSONException("Wrong JSON type in playlist append response", json);
562             }
563 
564             const errorJSON = "error" in json;
565             if (!errorJSON) return json;  // Success
566 
567             if (const messageJSON = "message" in errorJSON.object)
568             {
569                 if (messageJSON.str == "The access token expired")
570                 {
571                     if (recursing)
572                     {
573                         throw new InvalidCredentialsException(messageJSON.str, *errorJSON);
574                     }
575                     else
576                     {
577                         refreshSpotifyToken(getHTTPClient(), creds);
578                         saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile);
579                         return addTrackToSpotifyPlaylist(plugin, creds, trackID, Yes.recursing);
580                     }
581                 }
582 
583                 throw new ErrorJSONException(messageJSON.str, *errorJSON);
584             }
585 
586             // If we're here, the above didn't match
587             throw new ErrorJSONException(errorJSON.object["message"].str, *errorJSON);
588         }
589         catch (Exception e)
590         {
591             // Retry until we reach the retry limit, then rethrow
592             if (i < TwitchPlugin.delegateRetries-1) continue;
593             throw e;
594         }
595     }
596 
597     assert(0, "Unreachable");
598 }
599 
600 
601 // getSpotifyTrackByID
602 /++
603     Fetches information about a Spotify track by its ID and returns the JSON response.
604 
605     Params:
606         creds = [Credentials] aggregate.
607         trackID = Spotify track ID string.
608 
609     Returns:
610         A [std.json.JSONValue|JSONValue] of the response.
611 
612     Throws:
613         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
614         on unexpected JSON.
615 
616         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
617         if the returned JSON has an `"error"` field.
618  +/
619 package auto getSpotifyTrackByID(Credentials creds, const string trackID)
620 {
621     import arsd.http2 : Uri;
622     import std.algorithm.searching : endsWith;
623     import std.format : format;
624     import std.json : JSONType, parseJSON;
625 
626     enum urlPattern = "https://api.spotify.com/v1/tracks/%s";
627     immutable url = urlPattern.format(trackID);
628     auto client = getHTTPClient();
629 
630     if (!client.authorization.length || !client.authorization.endsWith(creds.spotifyAccessToken))
631     {
632         client.authorization = "Bearer " ~ creds.spotifyAccessToken;
633     }
634 
635     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
636     {
637         try
638         {
639             auto req = client.request(Uri(url));
640             auto res = req.waitForCompletion();
641             auto json = parseJSON(res.contentText);
642 
643             if (json.type != JSONType.object)
644             {
645                 throw new UnexpectedJSONException("Wrong JSON type in track request response", json);
646             }
647 
648             if (auto errorJSON = "error" in json)
649             {
650                 throw new ErrorJSONException(errorJSON.str, *errorJSON);
651             }
652 
653             return json;
654         }
655         catch (Exception e)
656         {
657             // Retry until we reach the retry limit, then rethrow
658             if (i < TwitchPlugin.delegateRetries-1) continue;
659             throw e;
660         }
661     }
662 
663     assert(0, "Unreachable");
664 }
665 
666 
667 // validateSpotifyToken
668 /++
669     Validates a Spotify OAuth token by issuing a simple request for user
670     information, returning the JSON received.
671 
672     Params:
673         client = [arsd.http2.HttpClient|HttpClient] to use.
674         creds = [Credentials] aggregate.
675 
676     Returns:
677         The server [std.json.JSONValue|JSONValue] response.
678 
679     Throws:
680         [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException]
681         on unexpected JSON.
682 
683         [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException]
684         if the returned JSON has an `"error"` field.
685  +/
686 auto validateSpotifyToken(HttpClient client, ref Credentials creds)
687 {
688     import arsd.http2 : Uri;
689     import std.json : JSONType, parseJSON;
690 
691     enum url = "https://api.spotify.com/v1/me";
692     client.authorization = "Bearer " ~ creds.spotifyAccessToken;
693 
694     foreach (immutable i; 0..TwitchPlugin.delegateRetries)
695     {
696         try
697         {
698             auto req = client.request(Uri(url));
699             auto res = req.waitForCompletion();
700             const json = parseJSON(res.contentText);
701 
702             /*
703             {
704                 "error": {
705                     "message": "The access token expired",
706                     "status": 401
707                 }
708             }
709             */
710             /*
711             {
712                 "display_name": "zorael",
713                 "external_urls": {
714                     "spotify": "https:\/\/open.spotify.com\/user\/zorael"
715                 },
716                 "followers": {
717                     "href": null,
718                     "total": 0
719                 },
720                 "href": "https:\/\/api.spotify.com\/v1\/users\/zorael",
721                 "id": "zorael",
722                 "images": [],
723                 "type": "user",
724                 "uri": "spotify:user:zorael"
725             }
726             */
727 
728             if (json.type != JSONType.object)
729             {
730                 throw new UnexpectedJSONException("Wrong JSON type in token validation response", json);
731             }
732 
733             if (auto errorJSON = "error" in json)
734             {
735                 throw new ErrorJSONException(errorJSON.str, *errorJSON);
736             }
737 
738             return json;
739         }
740         catch (Exception e)
741         {
742             // Retry until we reach the retry limit, then rethrow
743             if (i < TwitchPlugin.delegateRetries-1) continue;
744             throw e;
745         }
746     }
747 
748     assert(0, "Unreachable");
749 }