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 }