diff --git a/playlet-lib/src/components/ContentNode/ProfileContentNode.xml b/playlet-lib/src/components/ContentNode/ProfileContentNode.xml index 854b1ed0..f073fd76 100644 --- a/playlet-lib/src/components/ContentNode/ProfileContentNode.xml +++ b/playlet-lib/src/components/ContentNode/ProfileContentNode.xml @@ -8,6 +8,7 @@ + diff --git a/playlet-lib/src/components/Dialog/YouTubeLoginDialog.bs b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.bs new file mode 100644 index 00000000..9e5afbd1 --- /dev/null +++ b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.bs @@ -0,0 +1,51 @@ +import "pkg:/source/AsyncTask/AsyncTask.bs" +import "pkg:/source/AsyncTask/Tasks.bs" +import "pkg:/source/utils/Locale.bs" +import "pkg:/source/utils/Types.bs" + +function Init() + m.qrCode = m.top.findNode("QrCodePoster") + + codeLabel = m.top.findNode("codeLabel") + if codeLabel <> invalid + codeLabel = codeLabel.getChild(0) + if codeLabel <> invalid + codeFont = m.top.findNode("codeFont") + codeLabel.font = codeFont + end if + end if + + m.top.width = "920" + m.top.observeFieldScoped("buttonSelected", FuncName(Close)) + m.top.observeFieldScoped("wasClosed", FuncName(OnWasClosed)) + scanLabel = m.top.findNode("scanLabel") + ' TODO:P2 localize all dialog items + scanLabel.text = Tr(Locale.Dialogs.ScanTheQrCode) +end function + +function OnNodeReady() + m.task = AsyncTask.Start(Tasks.YouTubeLoginTask, { + dialog: m.top + profilesService: m.top.profilesService + }) +end function + +function OnCodeSet(event as object) + code = event.getData() + m.qrCode.text = code +end function + +function Close() + m.top.close = true +end function + +function OnWasClosed() + if m.task <> invalid + m.task.cancel = true + m.task = invalid + end if +end function + +function OnUrlSet() + m.qrCode.text = m.top.url +end function diff --git a/playlet-lib/src/components/Dialog/YouTubeLoginDialog.xml b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.xml new file mode 100644 index 00000000..795f0cda --- /dev/null +++ b/playlet-lib/src/components/Dialog/YouTubeLoginDialog.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/playlet-lib/src/components/Dialog/YouTubeLoginTask.bs b/playlet-lib/src/components/Dialog/YouTubeLoginTask.bs new file mode 100644 index 00000000..8d426069 --- /dev/null +++ b/playlet-lib/src/components/Dialog/YouTubeLoginTask.bs @@ -0,0 +1,118 @@ +import "pkg:/components/Dialog/DialogUtils.bs" +import "pkg:/components/Services/Innertube/InnertubeService.bs" +import "pkg:/source/utils/CancellationUtils.bs" +import "pkg:/source/utils/Logging.bs" + +@asynctask +function YouTubeLoginTask(input as object) as object + profilesNode = input.profilesService + dialogNode = input.dialog + + cancellation = m.top.cancellation + ' TODO:P2 cache client identity + authCode = InnertubeService.AuthGetCode(cancellation) + + if CancellationUtils.IsCancelled(cancellation) + return invalid + end if + + if authCode.error <> invalid + LogError(authCode.error) + DialogUtils.ShowDialogEx({ + title: "Error" + message: authCode.error + large: true + }) + return invalid + end if + + url = InnertubeService.AuthGetActivationUrl(authCode) + + LogInfo("Login url:", url) + + dialogNode.url = url + dialogNode.code = authCode.userCode + + accessToken = InnertubeService.AuthPollForAccessToken(authCode, cancellation) + + if CancellationUtils.IsCancelled(cancellation) + return invalid + end if + + if accessToken.error <> invalid + LogError(accessToken.error) + DialogUtils.ShowDialogEx({ + title: "Error" + message: accessToken.error + large: true + }) + return invalid + end if + + accounts = InnertubeService.AuthListAccounts(accessToken.accessToken, cancellation) + + if CancellationUtils.IsCancelled(cancellation) + return invalid + end if + + if IsAssociativeArray(accounts) and accounts.error <> invalid + LogError(accounts.error) + DialogUtils.ShowDialogEx({ + title: "Error" + message: accounts.error + large: true + }) + return invalid + end if + + if not IsArray(accounts) or accounts.Count() = 0 + LogError("No accounts found") + DialogUtils.ShowDialogEx({ + title: "Error" + message: "No accounts found" + large: true + }) + return invalid + end if + + selectedAccount = invalid + for each account in accounts + if ValidBool(account.isSelected) + selectedAccount = account + exit for + end if + end for + + if selectedAccount = invalid + selectedAccount = accounts[0] + end if + + profile = CreateProfileContentNode(selectedAccount, accessToken) + profilesNode@.LoginWithProfile(profile) + + dialogNode.close = true + + return invalid +end function + +function CreateProfileContentNode(account as object, accessToken as object) as object + profile = CreateObject("roSGNode", "ProfileContentNode") + profile.type = "youtube" + profile.serverUrl = "http://127.0.0.1:8888/playlet-invidious-backend" + username = account.channelHandle + if StringUtils.IsNullOrEmpty(username) + username = account.accountByline + end if + profile.username = username + profile.thumbnail = account.accountPhoto + profile.accessToken = accessToken.accessToken + profile.refreshToken = accessToken.refreshToken + profile.scope = accessToken.scope + profile.tokenType = accessToken.tokenType + profile.expiresIn = accessToken.expiresIn + profile.expiresTimestamp = accessToken.expiresTimestamp + profile.clientId = accessToken.clientId + profile.clientSecret = accessToken.clientSecret + + return profile +end function diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs b/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs index e87fb5ac..386fbc4e 100644 --- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs +++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileRowList/ProfileRowListCell.bs @@ -24,24 +24,37 @@ function OnContentSet() as void return end if - m.top.circlePosterInnerUri = "pkg:/images/white-circle.png" - m.top.circlePosterInnerBlendColor = content.color username = content.username m.top.username = username - if not StringUtils.IsNullOrEmpty(username) - m.top.letter = UCase(username.Left(1)) - else + + thumbnail = content.thumbnail + if not StringUtils.IsNullOrEmpty(thumbnail) + m.top.circlePosterInnerUri = thumbnail + ' bs:disable-next-line LINT3023 + m.top.circlePosterInnerBlendColor = "#FFFFFFFF" m.top.letter = "" + else + m.top.circlePosterInnerUri = "pkg:/images/white-circle.png" + m.top.circlePosterInnerBlendColor = content.color + + if not StringUtils.IsNullOrEmpty(username) + m.top.letter = UCase(username.Left(1)) + else + m.top.letter = "" + end if end if - m.top.serverUrl = content.serverUrl + m.top.crownVisible = content.isSelected backendType = content.type if backendType = "invidious" + m.top.serverUrl = content.serverUrl m.top.backendTypePosterUri = "pkg:/images/invidious-logo.png" else if backendType = "youtube" + m.top.serverUrl = "YouTube" m.top.backendTypePosterUri = "pkg:/images/youtube-logo.png" else + m.top.serverUrl = "" m.top.backendTypePosterUri = "" end if end function diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs b/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs index cbdd977c..d4acb380 100644 --- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs +++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileScreen.bs @@ -61,7 +61,7 @@ function OnBackEndSelected(event as object) as void if selectedBackendType = "BackendTypeInvidious" LoginWithInvidious() else if selectedBackendType = "BackendTypeYouTube" - LoginWithYouTube() + ShowLoginWithYouTubeDialog() else LogError("Unknown backend type:", selectedBackendType) end if @@ -85,11 +85,32 @@ function LoginWithInvidious() as void m.top.getScene().dialog = dialog end function -function LoginWithYouTube() as void - DialogUtils.ShowDialogEx({ - title: "YouTube" - message: ["Not implemented yet."] +function ShowLoginWithYouTubeDialog() as void + ' TODO:P2 localize + dialog = DialogUtils.ShowDialogEx({ + title: "Disclaimer" + message: "Playlet authors acknowledge that all trademarks and registered trademarks mentioned in this application and related pages are the property of their respective owners. The use of these trademarks or trade names is for identification purposes only and does not imply any endorsement, affiliation, or sponsorship by the trademark owner." + buttons: [ + Tr(Locale.Buttons.OK) + Tr(Locale.Buttons.Cancel) + ] }) + dialog.ObserveField("buttonSelected", FuncName(OnLoginWithYouTubeDialogButtonSelected)) +end function + +function OnLoginWithYouTubeDialogButtonSelected(event as object) as void + buttonSelected = event.getData() + if buttonSelected <> 0 + return + end if + + LoginWithYouTube() +end function + +function LoginWithYouTube() as void + dialog = CreateObject("roSGNode", "YouTubeLoginDialog") + dialog@.BindNode() + m.top.getScene().dialog = dialog end function function OnCurrentProfileChanged() as void diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs index cd396bb4..1e35a5c9 100644 --- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs +++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.bs @@ -43,6 +43,16 @@ function OnContentSet() as void end if m.top.serverUrl = content.serverUrl m.top.crownVisible = content.isSelected + + backendType = content.type + if backendType = "invidious" + m.top.backendTypePosterUri = "pkg:/images/invidious-logo.png" + else if backendType = "youtube" + m.top.backendTypePosterUri = "pkg:/images/youtube-logo.png" + else + m.top.backendTypePosterUri = "" + end if + UpdateActivateButton() end function diff --git a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml index 79be8c7e..be4b9ea2 100644 --- a/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml +++ b/playlet-lib/src/components/Screens/ProfileScreen/ProfileView/ProfileView.xml @@ -5,6 +5,7 @@ + @@ -47,6 +48,12 @@ horizAlign="center"> + invalid + return result end if result2 = Innertube.GetDeviceAndUserCode(result.clientId, cancellation) - if CancellationUtils.IsCancelled(cancellation) - return invalid + if CancellationUtils.IsCancelled(cancellation) or result2.error <> invalid + return result2 end if result.Append(result2) @@ -508,4 +508,7 @@ namespace InnertubeService return Innertube.RevokeAccessToken(accessToken["accessToken"], cancellation) end function + function AuthListAccounts(accessToken as string, cancellation = invalid as object) as object + return Innertube.ListAccounts(accessToken, cancellation) + end function end namespace diff --git a/playlet-lib/src/components/Services/Innertube/OAuth.bs b/playlet-lib/src/components/Services/Innertube/OAuth.bs index 2743d1fb..6cbc80e4 100644 --- a/playlet-lib/src/components/Services/Innertube/OAuth.bs +++ b/playlet-lib/src/components/Services/Innertube/OAuth.bs @@ -1,4 +1,5 @@ import "pkg:/source/services/HttpClient.bs" +import "pkg:/source/utils/ObjectUtils.bs" import "pkg:/source/utils/TimeUtils.bs" namespace Innertube @@ -14,7 +15,9 @@ namespace Innertube response = request.Await() if not response.IsSuccess() - throw `Failed to get client id: ${response.ErrorMessage()}` + return { + "error": `Failed to get client id: ${response.ErrorMessage()}` + } end if text = response.Text() @@ -23,7 +26,9 @@ namespace Innertube match = scriptRegex.Match(text) if match.Count() < 2 - throw "Could not find base-js script" + return { + "error": "Could not find base-js script" + } end if baseJsUrl = "https://www.youtube.com" + match[1] @@ -33,7 +38,9 @@ namespace Innertube response = request.Await() if not response.IsSuccess() - throw `Failed to get base js: ${response.ErrorMessage()}` + return { + "error": `Failed to get base js: ${response.ErrorMessage()}` + } end if text = response.Text() @@ -42,7 +49,9 @@ namespace Innertube match = clientIdRegex.Match(text) if match.Count() < 3 - throw "Could not find client id" + return { + "error": "Could not find client id and secret" + } end if return { @@ -66,13 +75,17 @@ namespace Innertube response = request.Await() if not response.IsSuccess() - throw `Failed to get device code: ${response.ErrorMessage()}` + return { + "error": `Failed to get device code: ${response.ErrorMessage()}` + } end if responseData = ToCamelCase(response.Json()) if responseData.DoesExist("errorCode") - throw "Failed to get device code: " + ToString(responseData) + return { + "error": "Failed to get device code: " + ToString(responseData) + } end if if responseData.DoesExist("expiresIn") @@ -130,11 +143,13 @@ namespace Innertube end if continue while else if responseData.error = "expired_token" - throw "Failed to get access token: " + ToString(responseData) + return responseData else if responseData.error = "access_denied" - throw "Failed to get access token: " + ToString(responseData) + return responseData else - throw "Failed to get access token: " + ToString(responseData) + return { + "error": "Failed to get access token: " + ToString(responseData) + } end if end while @@ -177,6 +192,64 @@ namespace Innertube return success end function + function ListAccounts(accessToken as string, cancellation = invalid as dynamic) as object + deviceInfo = CreateObject("roDeviceInfo") + + payload = { + "context": Innertube.CreateContext(Innertube.ClientType.Tv, deviceInfo, "", "") + "accountReadMask": { + "returnOwner": true + "returnBrandAccounts": true + "returnPersonaAccounts": true + "returnFamilyChildAccounts": true + } + } + + request = HttpClient.PostJson("https://www.youtube.com/youtubei/v1/account/accounts_list", payload) + headers = { + "Authorization": `Bearer ${accessToken}` + } + headers.Append(Innertube.CreateHeaders(Innertube.ClientType.Tv)) + request.Headers(headers) + request.Cancellation(cancellation) + + response = request.Await() + + if not response.IsSuccess() + return { + "error": `Failed to list accounts: ${response.ErrorMessage()}` + } + end if + + accounts = ObjectUtils.Dig(response.Json(), ["contents", 0, "accountSectionListRenderer", "contents", 0, "accountItemSectionRenderer", "contents"]) + if not IsArray(accounts) + return { + "error": "Could not parse accounts" + } + end if + + parsedAccounts = [] + for each account in accounts + accountItem = account["accountItem"] + if not IsAssociativeArray(accountItem) + continue for + end if + + parsedAccount = { + "accountName": ParseText(accountItem["accountName"]) + "accountPhoto": ObjectUtils.Dig(accountItem, ["accountPhoto", "thumbnails", 0, "url"]) + "isSelected": ValidBool(accountItem["isSelected"]) + "isDisabled": ValidBool(accountItem["isDisabled"]) + "hasChannel": ValidBool(accountItem["hasChannel"]) + "accountByline": ParseText(accountItem["accountByline"]) + "channelHandle": ParseText(accountItem["channelHandle"]) + } + parsedAccounts.Push(parsedAccount) + end for + + return parsedAccounts + end function + function ToCamelCase(obj as object) as object if not IsAssociativeArray(obj) return obj diff --git a/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs b/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs index 8027d43d..6f873ad2 100644 --- a/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs +++ b/playlet-lib/src/components/Services/ProfilesService/ProfilesService.bs @@ -50,6 +50,7 @@ function LoadProfilesFromRegistry() profileNode.type = profile.type profileNode.username = profile.username profileNode.serverUrl = profile.serverUrl + profileNode.thumbnail = profile.thumbnail profileNode.accessToken = profile.accessToken profileNode.refreshToken = profile.refreshToken @@ -161,6 +162,7 @@ function GetProfilesDto(includeAccessToken as boolean) as object "type": profileNode.type "username": profileNode.username "serverUrl": profileNode.serverUrl + "thumbnail": profileNode.thumbnail "color": color } @@ -203,8 +205,17 @@ function LoginWithProfile(newProfile as object) end for if not isNewProfile - ' Refresh token of existing profile + existingProfile.username = newProfile.username + existingProfile.thumbnail = newProfile.thumbnail existingProfile.accessToken = newProfile.accessToken + existingProfile.refreshToken = newProfile.refreshToken + existingProfile.scope = newProfile.scope + existingProfile.tokenType = newProfile.tokenType + existingProfile.expiresIn = newProfile.expiresIn + existingProfile.expiresTimestamp = newProfile.expiresTimestamp + existingProfile.clientId = newProfile.clientId + existingProfile.clientSecret = newProfile.clientSecret + newProfile = existingProfile end if