1 /++ 2 Bits and bobs to get Google API credentials for YouTube playlist management. 3 4 See_Also: 5 [kameloso.plugins.twitch.base], 6 [kameloso.plugins.twitch.keygen], 7 [kameloso.plugins.twitch.api] 8 9 Copyright: [JR](https://github.com/zorael) 10 License: [Boost Software License 1.0](https://www.boost.org/users/license.html) 11 12 Authors: 13 [JR](https://github.com/zorael) 14 +/ 15 module kameloso.plugins.twitch.google; 16 17 version(TwitchSupport): 18 version(WithTwitchPlugin): 19 20 private: 21 22 import kameloso.plugins.twitch.base; 23 import kameloso.plugins.twitch.common; 24 25 import kameloso.common : logger; 26 import arsd.http2 : HttpClient; 27 import std.json : JSONValue; 28 import std.typecons : Flag, No, Yes; 29 import core.thread : Fiber; 30 31 32 // requestGoogleKeys 33 /++ 34 Requests a Google API authorisation code from Google servers, then uses it 35 to obtain an access key and a refresh OAuth key. 36 37 Params: 38 plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin]. 39 40 Throws: 41 [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException] 42 if the returned JSON has an `"error"` field. 43 +/ 44 package void requestGoogleKeys(TwitchPlugin plugin) 45 { 46 import kameloso.logger : LogLevel; 47 import kameloso.terminal.colours.tags : expandTags; 48 import kameloso.time : timeSince; 49 import lu.string : contains, nom, stripped; 50 import std.conv : to; 51 import std.format : format; 52 import std.process : Pid, ProcessException, wait; 53 import std.stdio : File, readln, stdin, stdout, write, writeln; 54 import core.time : seconds; 55 56 scope(exit) if (plugin.state.settings.flush) stdout.flush(); 57 58 logger.trace(); 59 logger.info("== Google authorisation key generation mode =="); 60 enum message = ` 61 To access the Google API you need a <i>client ID</> and a <i>client secret</>. 62 63 <l>Go here to create a project:</> 64 65 <i>https://console.cloud.google.com/projectcreate</> 66 67 <l>OAuth consent screen</> tab (choose <i>External</>), follow instructions. 68 <i>*</> <l>Scopes:</> <i>https://www.googleapis.com/auth/youtube</> 69 <i>*</> <l>Test users:</> (your Google account) 70 71 Then pick <i>+ Create Credentials</> -> <i>OAuth client ID</>: 72 <i>*</> <l>Application type:</> <i>Desktop app</> 73 74 Now you should have a newly-generated client ID and client secret. 75 Copy these somewhere; you'll need them soon. 76 77 <l>Enabled APIs and Services</> tab -> <i>+ Enable APIs and Services</> 78 <i>--></> enter "<i>YouTube Data API v3</>", hit <i>Enable</> 79 80 You also need to supply a channel for which it all relates. 81 (Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign.) 82 83 Lastly you need a <i>YouTube playlist ID</> for song requests to work. 84 A normal URL to any playlist you can modify will work fine. 85 `; 86 writeln(message.expandTags(LogLevel.off)); 87 88 Credentials creds; 89 90 string channel; 91 while (!channel.length) 92 { 93 immutable rawChannel = readNamedString("<l>Enter your <i>#channel<l>:</> ", 94 0L, *plugin.state.abort); 95 if (*plugin.state.abort) return; 96 97 channel = rawChannel.stripped; 98 99 if (!channel.length || channel[0] != '#') 100 { 101 enum channelMessage = "Channels are Twitch lowercase account names, prepended with a '<i>#</>' sign."; 102 logger.warning(channelMessage); 103 channel = string.init; 104 } 105 } 106 107 creds.googleClientID = readNamedString("<l>Copy and paste your <i>OAuth client ID<l>:</> ", 108 72L, *plugin.state.abort); 109 if (*plugin.state.abort) return; 110 111 creds.googleClientSecret = readNamedString("<l>Copy and paste your <i>OAuth client secret<l>:</> ", 112 35L, *plugin.state.abort); 113 if (*plugin.state.abort) return; 114 115 while (!creds.youtubePlaylistID.length) 116 { 117 enum playlistIDLength = 34; 118 119 immutable playlistURL = readNamedString("<l>Copy and paste your <i>YouTube playlist URL<l>:</> ", 120 0L, *plugin.state.abort); 121 if (*plugin.state.abort) return; 122 123 if (playlistURL.length == playlistIDLength) 124 { 125 // Likely a playlist ID 126 creds.youtubePlaylistID = playlistURL; 127 } 128 else if (playlistURL.contains("/playlist?list=")) 129 { 130 string slice = playlistURL; // mutable 131 slice.nom("/playlist?list="); 132 creds.youtubePlaylistID = slice.nom!(Yes.inherit)('&'); 133 } 134 else 135 { 136 writeln(); 137 enum invalidMessage = "Cannot recognise link as a YouTube playlist URL. " ~ 138 "Try copying again or file a bug."; 139 logger.error(invalidMessage); 140 writeln(); 141 continue; 142 } 143 } 144 145 enum attemptToOpenPattern = ` 146 -------------------------------------------------------------------------------- 147 148 <l>Attempting to open a Google login page in your default web browser.</> 149 150 Follow the instructions and log in to authorise the use of this program with your account. 151 Be sure to <l>select a YouTube account</> if presented with several alternatives. 152 (One that says <i>YouTube</> underneath it.) 153 154 <l>Then paste the address of the empty page you are redirected to afterwards here.</> 155 156 * The redirected address should start with <i>http://localhost</>. 157 * It will probably say "<l>this site can't be reached</>" or "<l>unable to connect</>". 158 * If you are running local web server on port <i>80</>, you may have to temporarily 159 disable it for this to work. 160 `; 161 writeln(attemptToOpenPattern.expandTags(LogLevel.off)); 162 if (plugin.state.settings.flush) stdout.flush(); 163 164 enum authNode = "https://accounts.google.com/o/oauth2/v2/auth"; 165 enum urlPattern = authNode ~ 166 "?client_id=%s" ~ 167 "&redirect_uri=http://localhost" ~ 168 "&response_type=code" ~ 169 "&scope=https://www.googleapis.com/auth/youtube"; 170 immutable url = urlPattern.format(creds.googleClientID); 171 172 Pid browser; 173 scope(exit) if (browser !is null) wait(browser); 174 175 if (plugin.state.settings.force) 176 { 177 logger.warning("Forcing; not automatically opening browser."); 178 printManualURL(url); 179 if (plugin.state.settings.flush) stdout.flush(); 180 } 181 else 182 { 183 try 184 { 185 import kameloso.platform : openInBrowser; 186 browser = openInBrowser(url); 187 } 188 catch (ProcessException _) 189 { 190 // Probably we got some platform wrong and command was not found 191 logger.warning("Error: could not automatically open browser."); 192 printManualURL(url); 193 if (plugin.state.settings.flush) stdout.flush(); 194 } 195 catch (Exception _) 196 { 197 logger.warning("Error: no graphical environment detected"); 198 printManualURL(url); 199 if (plugin.state.settings.flush) stdout.flush(); 200 } 201 } 202 203 string code; 204 205 while (!code.length) 206 { 207 scope(exit) if (plugin.state.settings.flush) stdout.flush(); 208 209 enum pasteMessage = "<l>Paste the address of the page you were redirected to here (empty line exits):</> 210 211 > "; 212 write(pasteMessage.expandTags(LogLevel.off)); 213 stdout.flush(); 214 215 stdin.flush(); 216 immutable readCode = readln().stripped; 217 218 if (*plugin.state.abort || !readCode.length) 219 { 220 writeln(); 221 logger.warning("Aborting."); 222 logger.trace(); 223 *plugin.state.abort = true; 224 return; 225 } 226 227 if (!readCode.contains("code=")) 228 { 229 import lu.string : beginsWith; 230 231 writeln(); 232 233 if (readCode.beginsWith(authNode)) 234 { 235 enum wrongPageMessage = "Not that page; the empty page you're " ~ 236 "lead to after clicking <l>Allow</>."; 237 logger.error(wrongPageMessage); 238 } 239 else 240 { 241 logger.error("Could not make sense of URL. Try again or file a bug."); 242 } 243 244 writeln(); 245 continue; 246 } 247 248 string slice = readCode; // mutable 249 slice.nom("?code="); 250 code = slice.nom!(Yes.inherit)('&'); 251 252 if (code.length != 73L) 253 { 254 writeln(); 255 logger.error("Invalid code length. Try copying again or file a bug."); 256 writeln(); 257 code = string.init; // reset it so the while loop repeats 258 } 259 } 260 261 // All done, fetch 262 auto client = getHTTPClient(); 263 getGoogleTokens(client, creds, code); 264 265 writeln(); 266 logger.info("Validating..."); 267 268 immutable validationJSON = validateGoogleToken(client, creds); 269 if (*plugin.state.abort) return; 270 271 scope(failure) 272 { 273 import std.stdio : writeln; 274 writeln(validationJSON.toPrettyString); 275 } 276 277 if (const errorJSON = "error" in validationJSON) 278 { 279 throw new ErrorJSONException(validationJSON["error_description"].str, validationJSON); 280 } 281 282 // "expires_in" is a string 283 immutable expiresIn = validationJSON["expires_in"].str.to!uint; 284 285 enum isValidPattern = "Your key is valid for another <l>%s</> but will be automatically refreshed."; 286 logger.infof(isValidPattern, expiresIn.seconds.timeSince!(3, 1)); 287 logger.trace(); 288 289 if (auto storedCreds = channel in plugin.secretsByChannel) 290 { 291 import lu.meld : MeldingStrategy, meldInto; 292 creds.meldInto!(MeldingStrategy.aggressive)(*storedCreds); 293 } 294 else 295 { 296 plugin.secretsByChannel[channel] = creds; 297 } 298 299 saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile); 300 } 301 302 303 // addVideoToYouTubePlaylist 304 /++ 305 Adds a video to the YouTube playlist whose ID is stored in the passed [Credentials]. 306 307 Note: Must be called from inside a [core.thread.fiber.Fiber|Fiber]. 308 309 Params: 310 plugin = The current [kameloso.plugins.twitch.base.TwitchPlugin|TwitchPlugin]. 311 creds = [Credentials] aggregate. 312 videoID = YouTube video ID of the video to add. 313 recursing = Whether or not the function is recursing into itself. 314 315 Returns: 316 A [std.json.JSONValue|JSONValue] of the response. 317 318 Throws: 319 [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException] 320 on unexpected JSON. 321 322 [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException] 323 if the returned JSON has an `"error"` field. 324 +/ 325 package JSONValue addVideoToYouTubePlaylist( 326 TwitchPlugin plugin, 327 ref Credentials creds, 328 const string videoID, 329 const Flag!"recursing" recursing = No.recursing) 330 in (Fiber.getThis, "Tried to call `addVideoToYouTubePlaylist` from outside a Fiber") 331 { 332 import kameloso.plugins.twitch.api : getUniqueNumericalID, waitForQueryResponse; 333 import kameloso.plugins.common.delayawait : delay; 334 import kameloso.thread : ThreadMessage; 335 import arsd.http2 : HttpVerb; 336 import std.algorithm.searching : endsWith; 337 import std.concurrency : prioritySend, send; 338 import std.format : format; 339 import std.json : JSONType, parseJSON; 340 import std.string : representation; 341 import core.time : msecs; 342 343 enum url = "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet"; 344 345 if (plugin.state.settings.trace) 346 { 347 import kameloso.common : logger; 348 enum pattern = "GET: <i>%s"; 349 logger.tracef(pattern, url); 350 } 351 352 static string authorizationBearer; 353 354 if (!authorizationBearer.length || !authorizationBearer.endsWith(creds.googleAccessToken)) 355 { 356 authorizationBearer = "Bearer " ~ creds.googleAccessToken; 357 } 358 359 enum pattern = 360 `{ 361 "snippet": { 362 "playlistId": "%s", 363 "resourceId": { 364 "kind": "youtube#video", 365 "videoId": "%s" 366 } 367 } 368 }`; 369 370 immutable data = pattern.format(creds.youtubePlaylistID, videoID).representation; 371 /*immutable*/ int id = getUniqueNumericalID(plugin.bucket); // Making immutable bumps compilation memory +44mb 372 373 foreach (immutable i; 0..TwitchPlugin.delegateRetries) 374 { 375 try 376 { 377 plugin.state.mainThread.prioritySend(ThreadMessage.shortenReceiveTimeout()); 378 379 plugin.persistentWorkerTid.send( 380 id, 381 url, 382 authorizationBearer, 383 HttpVerb.POST, 384 data, 385 "application/json"); 386 387 static immutable guesstimatePeriodToWaitForCompletion = 600.msecs; 388 delay(plugin, guesstimatePeriodToWaitForCompletion, Yes.yield); 389 immutable response = waitForQueryResponse(plugin, id); 390 391 /* 392 { 393 "kind": "youtube#playlistItem", 394 "etag": "QG1leAsBIlxoG2Y4MxMsV_zIaD8", 395 "id": "UExNNnd5dmt2ME9GTVVfc0IwRUZyWDdUd0pZUHdkMUYwRi4xMkVGQjNCMUM1N0RFNEUx", 396 "snippet": { 397 "publishedAt": "2022-05-24T22:03:44Z", 398 "channelId": "UC_iiOE42xes48ZXeQ4FkKAw", 399 "title": "How Do Sinkholes Form?", 400 "description": "CAN CONTAIN NEWLINES", 401 "thumbnails": { 402 "default": { 403 "url": "https://i.ytimg.com/vi/e-DVIQPqS8E/default.jpg", 404 "width": 120, 405 "height": 90 406 }, 407 }, 408 "channelTitle": "zorael", 409 "playlistId": "PLM6wyvkv0OFMU_sB0EFrX7TwJYPwd1F0F", 410 "position": 5, 411 "resourceId": { 412 "kind": "youtube#video", 413 "videoId": "e-DVIQPqS8E" 414 }, 415 "videoOwnerChannelTitle": "Practical Engineering", 416 "videoOwnerChannelId": "UCMOqf8ab-42UUQIdVoKwjlQ" 417 } 418 } 419 */ 420 421 /* 422 { 423 "error": { 424 "code": 401, 425 "message": "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.", 426 "errors": [ 427 { 428 "message": "Invalid Credentials", 429 "domain": "global", 430 "reason": "authError", 431 "location": "Authorization", 432 "locationType": "header" 433 } 434 ], 435 "status": "UNAUTHENTICATED" 436 } 437 } 438 */ 439 440 const json = parseJSON(response.str); 441 442 if (json.type != JSONType.object) 443 { 444 enum message = "Wrong JSON type in playlist append response"; 445 throw new UnexpectedJSONException(message, json); 446 } 447 448 const errorJSON = "error" in json; 449 if (!errorJSON) return json; // Success 450 451 if (const statusJSON = "status" in errorJSON.object) 452 { 453 if (statusJSON.str == "UNAUTHENTICATED") 454 { 455 if (recursing) 456 { 457 const errorAAJSON = "errors" in *errorJSON; 458 459 if (errorAAJSON && 460 (errorAAJSON.type == JSONType.array) && 461 (errorAAJSON.array.length > 0)) 462 { 463 immutable message = errorAAJSON.array[0].object["message"].str; 464 throw new InvalidCredentialsException(message, *errorJSON); 465 } 466 else 467 { 468 enum message = "A non-specific error occurred."; 469 throw new ErrorJSONException(message, *errorJSON); 470 } 471 } 472 else 473 { 474 refreshGoogleToken(getHTTPClient(), creds); 475 saveSecretsToDisk(plugin.secretsByChannel, plugin.secretsFile); 476 return addVideoToYouTubePlaylist(plugin, creds, videoID, Yes.recursing); 477 } 478 } 479 } 480 481 // If we're here, the above didn't match 482 throw new ErrorJSONException(errorJSON.object["message"].str, *errorJSON); 483 } 484 catch (InvalidCredentialsException e) 485 { 486 // Immediately rethrow 487 throw e; 488 } 489 catch (Exception e) 490 { 491 // Retry until we reach the retry limit, then rethrow 492 if (i < TwitchPlugin.delegateRetries-1) continue; 493 throw e; 494 } 495 } 496 497 assert(0, "Unreachable"); 498 } 499 500 501 // getGoogleTokens 502 /++ 503 Request OAuth API tokens from Google. 504 505 Params: 506 client = [arsd.http2.HttpClient|HttpClient] to use. 507 creds = [Credentials] aggregate. 508 code = Google authorization code. 509 510 Throws: 511 [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException] 512 on unexpected JSON. 513 514 [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException] 515 if the returned JSON has an `"error"` field. 516 +/ 517 void getGoogleTokens(HttpClient client, ref Credentials creds, const string code) 518 { 519 import arsd.http2 : HttpVerb, Uri; 520 import std.format : format; 521 import std.json : JSONType, parseJSON; 522 import std.string : indexOf; 523 524 enum pattern = "https://oauth2.googleapis.com/token" ~ 525 "?client_id=%s" ~ 526 "&client_secret=%s" ~ 527 "&code=%s" ~ 528 "&grant_type=authorization_code" ~ 529 "&redirect_uri=http://localhost"; 530 531 immutable url = pattern.format(creds.googleClientID, creds.googleClientSecret, code); 532 enum data = cast(ubyte[])"{}"; 533 auto req = client.request(Uri(url), HttpVerb.POST, data); 534 auto res = req.waitForCompletion(); 535 536 /* 537 { 538 "access_token": "[redacted]" 539 "expires_in": 3599, 540 "refresh_token": "[redacted]", 541 "scope": "https://www.googleapis.com/auth/youtube", 542 "token_type": "Bearer" 543 } 544 */ 545 546 const json = parseJSON(res.contentText); 547 548 if (json.type != JSONType.object) 549 { 550 throw new UnexpectedJSONException("Wrong JSON type in token request response", json); 551 } 552 553 if (auto errorJSON = "error" in json) 554 { 555 throw new ErrorJSONException(errorJSON.str, *errorJSON); 556 } 557 558 creds.googleAccessToken = json["access_token"].str; 559 creds.googleRefreshToken = json["refresh_token"].str; 560 } 561 562 563 // refreshGoogleToken 564 /++ 565 Refreshes the OAuth API token in the passed Google credentials. 566 567 Params: 568 client = [arsd.http2.HttpClient|HttpClient] to use. 569 creds = [Credentials] aggregate. 570 571 Throws: 572 [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException] 573 on unexpected JSON. 574 575 [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException] 576 if the returned JSON has an `"error"` field. 577 +/ 578 void refreshGoogleToken(HttpClient client, ref Credentials creds) 579 { 580 import arsd.http2 : HttpVerb, Uri; 581 import std.format : format; 582 import std.json : JSONType, parseJSON; 583 584 enum pattern = "https://oauth2.googleapis.com/token" ~ 585 "?client_id=%s" ~ 586 "&client_secret=%s" ~ 587 "&refresh_token=%s" ~ 588 "&grant_type=refresh_token"; 589 590 immutable url = pattern.format(creds.googleClientID, creds.googleClientSecret, creds.googleRefreshToken); 591 enum data = cast(ubyte[])"{}"; 592 auto req = client.request(Uri(url), HttpVerb.POST, data); 593 auto res = req.waitForCompletion(); 594 const json = parseJSON(res.contentText); 595 596 if (json.type != JSONType.object) 597 { 598 throw new UnexpectedJSONException("Wrong JSON type in token refresh response", json); 599 } 600 601 if (auto errorJSON = "error" in json) 602 { 603 if (errorJSON.str == "invalid_grant") 604 { 605 enum message = "Invalid grant"; 606 throw new InvalidCredentialsException(message, *errorJSON); 607 } 608 else 609 { 610 throw new ErrorJSONException(errorJSON.str, *errorJSON); 611 } 612 } 613 614 creds.googleAccessToken = json["access_token"].str; 615 // refreshToken is not present and stays the same as before 616 } 617 618 619 // validateGoogleToken 620 /++ 621 Validates a Google OAuth token, returning the JSON received from the server. 622 623 Params: 624 client = [arsd.http2.HttpClient|HttpClient] to use. 625 creds = [Credentials] aggregate. 626 627 Returns: 628 The server [std.json.JSONValue|JSONValue] response. 629 630 Throws: 631 [kameloso.plugins.twitch.common.UnexpectedJSONException|UnexpectedJSONException] 632 on unexpected JSON. 633 634 [kameloso.plugins.twitch.common.ErrorJSONException|ErrorJSONException] 635 if the returned JSON has an `"error"` field. 636 +/ 637 auto validateGoogleToken(HttpClient client, ref Credentials creds) 638 { 639 import arsd.http2 : Uri; 640 import std.json : JSONType, parseJSON; 641 642 enum urlHead = "https://www.googleapis.com/oauth2/v3/tokeninfo?access_token="; 643 immutable url = urlHead ~ creds.googleAccessToken; 644 auto req = client.request(Uri(url)); 645 auto res = req.waitForCompletion(); 646 const json = parseJSON(res.contentText); 647 648 /* 649 { 650 "error": "invalid_token", 651 "error_description": "Invalid Value" 652 } 653 */ 654 /* 655 { 656 "access_type": "offline", 657 "aud": "[redacted]", 658 "azp": "[redacted]", 659 "exp": "[redacted]", 660 "expires_in": "3599", 661 "scope": "https:\/\/www.googleapis.com\/auth\/youtube" 662 } 663 */ 664 665 if (json.type != JSONType.object) 666 { 667 throw new UnexpectedJSONException("Wrong JSON type in token validation response", json); 668 } 669 670 if (auto errorJSON = "error" in json) 671 { 672 throw new ErrorJSONException(errorJSON.str, *errorJSON); 673 } 674 675 return json; 676 }