diff --git a/CHANGELOG.md b/CHANGELOG.md index 9397e296..33d49345 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Experimental Playlet backend to play videos using Innertube - Partial Spanish translations (Thanks to Kamborio and gallegonovato) ## [0.26.0] - 2024-09-23 diff --git a/playlet-lib/src/components/MainScene.bs b/playlet-lib/src/components/MainScene.bs index 38259cf4..a235133d 100644 --- a/playlet-lib/src/components/MainScene.bs +++ b/playlet-lib/src/components/MainScene.bs @@ -43,22 +43,13 @@ function GetDeviceFriendlyName() as string end function function ShowAnnouncement() - title = "Announcement #2 - web app hot fix" + title = "Announcement #3 - Playlet innertube backend" message = [ - "Invidious servers continue being blocked by YouTube. https://github.com/iBicha/playlet/issues/400" - "A temporary workaround has been implemented which relies on your Roku device instead of Invidious for streaming data. This workaround is limited, doesn't always work, and doesn't have captions.", - "How to use:" - ] - - bulletText = [ - `Go to the "Remote" screen, and open Playlet Web App in your browser` - "In the web app, tap the video you want to play" - `Choose "Play on ${GetDeviceFriendlyName()} (HOT FIX)"` - ] - - bottomMessage = [ - "We apologize for the inconvenience." + "Currently, most public Invidious instances don't currently work." + "Because of that, a new backend to play videos has been added to the settings, and it is enabled by default." + "If you would like to use your Invidious instance for playback, you can change the backend in the settings." + "Thank you." ] buttons = [Tr(Locale.Buttons.OK)] @@ -66,8 +57,6 @@ function ShowAnnouncement() dialog = CreateObject("roSGNode", "SimpleDialog") dialog.title = title dialog.message = message - dialog.bulletText = bulletText - dialog.bottomMessage = bottomMessage dialog.buttons = buttons deviceInfo = CreateObject("roDeviceInfo") displaySize = deviceInfo.GetDisplaySize() diff --git a/playlet-lib/src/components/MainScene.transpiled.brs b/playlet-lib/src/components/MainScene.transpiled.brs index 000a86db..54a336ff 100644 --- a/playlet-lib/src/components/MainScene.transpiled.brs +++ b/playlet-lib/src/components/MainScene.transpiled.brs @@ -42,19 +42,12 @@ function GetDeviceFriendlyName() as string end function function ShowAnnouncement() - title = "Announcement #2 - web app hot fix" + title = "Announcement #3 - Playlet innertube backend" message = [ - "Invidious servers continue being blocked by YouTube. https://github.com/iBicha/playlet/issues/400" - "A temporary workaround has been implemented which relies on your Roku device instead of Invidious for streaming data. This workaround is limited, doesn't always work, and doesn't have captions." - "How to use:" - ] - bulletText = [ - "Go to the " + chr(34) + "Remote" + chr(34) + " screen, and open Playlet Web App in your browser" - "In the web app, tap the video you want to play" - ("Choose " + chr(34) + "Play on " + bslib_toString(GetDeviceFriendlyName()) + " (HOT FIX)" + chr(34)) - ] - bottomMessage = [ - "We apologize for the inconvenience." + "Currently, most public Invidious instances don't currently work." + "Because of that, a new backend to play videos has been added to the settings, and it is enabled by default." + "If you would like to use your Invidious instance for playback, you can change the backend in the settings." + "Thank you." ] buttons = [ Tr("OK") @@ -62,8 +55,6 @@ function ShowAnnouncement() dialog = CreateObject("roSGNode", "SimpleDialog") dialog.title = title dialog.message = message - dialog.bulletText = bulletText - dialog.bottomMessage = bottomMessage dialog.buttons = buttons deviceInfo = CreateObject("roDeviceInfo") displaySize = deviceInfo.GetDisplaySize() diff --git a/playlet-lib/src/components/Services/Innertube/InnertubeService.bs b/playlet-lib/src/components/Services/Innertube/InnertubeService.bs new file mode 100644 index 00000000..fa2945cb --- /dev/null +++ b/playlet-lib/src/components/Services/Innertube/InnertubeService.bs @@ -0,0 +1,172 @@ +import "pkg:/source/services/HttpClient.bs" +import "pkg:/source/utils/TimeUtils.bs" + +namespace InnertubeService + + function GetVideoMetadata(videoId as string, options = invalid as object) as object + request = HttpClient.Post("https://www.youtube.com/youtubei/v1/player", FormatJson(MakePayload(videoId))) + request.Headers({ + "accept": "*/*" + "accept-language": "*" + "content-type": "application/json" + "user-agent": "com.google.ios.youtube/18.06.35 (iPhone; CPU iPhone OS 14_4 like Mac OS X; en_US)" + "x-youtube-client-name": "5" + "x-youtube-client-version": "18.06.35" + }) + + cancellation = invalid + + if options <> invalid + if options.DoesExist("cancellation") + cancellation = options.cancellation + end if + end if + request.Cancellation(cancellation) + + response = request.Await() + + if not response.IsSuccess() + return response + end if + + parsedResponse = ParseInnertubeVideoResponse(response.Json()) + + if parsedResponse <> invalid + if parsedResponse.Success + response.OverrideJson(parsedResponse.Metadata) + else + response.OverrideStatusCode(500) + response.OverrideErrorMessage(parsedResponse.Error) + end if + end if + + return response + end function + + function MakePayload(videoId as string) as object + deviceInfo = CreateObject("roDeviceInfo") + return { + "playbackContext": { + "contentPlaybackContext": { + "vis": 0 + "splay": false + "referer": "https://www.youtube.com/watch?v=" + videoId + "currentUrl": "/watch?v=" + videoId + "autonavState": "STATE_ON" + "autoCaptionsDefaultOn": false + "html5Preference": "HTML5_PREF_WANTS" + "lactMilliseconds": "-1" + } + } + "attestationRequest": { + "omitBotguardData": true + } + "racyCheckOk": true + "contentCheckOk": true + "videoId": videoId + "context": { + "client": { + "hl": "en" + "gl": "US" + "remoteHost": "" + "screenDensityFloat": 1 + "screenHeightPoints": 1440 + "screenPixelDensity": 1 + "screenWidthPoints": 2560 + "visitorData": "" + "clientName": "iOS" + "clientVersion": "18.06.35" + "osName": "iOS" + "osVersion": "10.0" + "platform": "MOBILE" + "clientFormFactor": "UNKNOWN_FORM_FACTOR" + "userInterfaceTheme": "USER_INTERFACE_THEME_LIGHT" + "timeZone": deviceInfo.GetTimeZone() + "originalUrl": "https://www.youtube.com" + "deviceMake": "Apple" + "deviceModel": "iPhone10,6" + "utcOffsetMinutes": -240 + "memoryTotalKbytes": "8000000" + "mainAppWebInfo": { + "graftUrl": "https://www.youtube.com" + "pwaInstallabilityStatus": "PWA_INSTALLABILITY_STATUS_UNKNOWN" + "webDisplayMode": "WEB_DISPLAY_MODE_BROWSER" + "isWebNativeShareAvailable": true + } + } + "user": { + "enableSafetyMode": false + "lockedSafetyMode": false + } + "request": { + "useSsl": true + "internalExperimentFlags": [] + } + } + } + end function + + function ParseInnertubeVideoResponse(payload as object) as object + if not IsAssociativeArray(payload) + return { + Success: false + Error: "Invalid payload" + } + end if + + playabilityStatus = payload["playabilityStatus"] + if not IsAssociativeArray(playabilityStatus) + return { + Success: false + Error: "Invalid playability status" + } + end if + + if playabilityStatus["status"] <> "OK" + return { + Success: false + Error: playabilityStatus["reason"] + } + end if + + videoDetails = payload["videoDetails"] + if not IsAssociativeArray(videoDetails) + return { + Success: false + Error: "Invalid video details" + } + end if + + streamingData = payload["streamingData"] + if not IsAssociativeArray(streamingData) + return { + Success: false + Error: "Invalid streaming data" + } + end if + + videoInfo = { + "type": "video" + "title": videoDetails["title"] + "videoId": videoDetails["videoId"] + "videoThumbnails": videoDetails["thumbnail"]["thumbnails"] + "storyboards": [] + "viewCount": videoDetails["viewCount"].ToInt() + "author": videoDetails["author"] + "authorId": videoDetails["channelId"] + "lengthSeconds": videoDetails["lengthSeconds"].ToInt() + "liveNow": videoDetails["isLiveContent"] + "hlsUrl": streamingData["hlsManifestUrl"] + "adaptiveFormats": [] + "formatStreams": [] + "captions": [] + "recommendedVideos": [] + } + + return { + Success: true + Metadata: videoInfo + } + end function + +end namespace diff --git a/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs b/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs index 077d781b..ade0f321 100644 --- a/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs +++ b/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs @@ -1,3 +1,4 @@ +import "pkg:/components/Services/Innertube/InnertubeService.bs" import "pkg:/components/Services/Invidious/InvidiousService.bs" @asynctask @@ -17,7 +18,12 @@ function VideoContentTask(input as object) as object service = new Invidious.InvidiousService(invidiousNode) - response = service.GetVideoMetadata(contentNode.videoId, { cancellation: m.top.cancellation }) + backend = preferencesNode["playback.backend"] + if backend = "playlet" + response = InnertubeService.GetVideoMetadata(contentNode.videoId, { cancellation: m.top.cancellation }) + else + response = service.GetVideoMetadata(contentNode.videoId, { cancellation: m.top.cancellation }) + end if if m.top.cancel return invalid diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueView.bs b/playlet-lib/src/components/VideoQueue/VideoQueueView.bs index c498be33..41c02488 100644 --- a/playlet-lib/src/components/VideoQueue/VideoQueueView.bs +++ b/playlet-lib/src/components/VideoQueue/VideoQueueView.bs @@ -144,6 +144,7 @@ function LoadVideoDetailsIfNeeded() as void m.loadDetailsTask = AsyncTask.Start(Tasks.VideoQueueViewContentTask, { videoNodes: m.videosDetailsToLoad invidious: m.top.invidious + preferences: m.top.preferences index: indexInLoadingArray }) end function diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueView.xml b/playlet-lib/src/components/VideoQueue/VideoQueueView.xml index ac4cc6c9..70b9d044 100644 --- a/playlet-lib/src/components/VideoQueue/VideoQueueView.xml +++ b/playlet-lib/src/components/VideoQueue/VideoQueueView.xml @@ -5,6 +5,7 @@ + diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs b/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs index 86b3b372..dd867fa9 100644 --- a/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs +++ b/playlet-lib/src/components/VideoQueue/VideoQueueViewContentTask.bs @@ -1,3 +1,4 @@ +import "pkg:/components/Services/Innertube/InnertubeService.bs" import "pkg:/components/Services/Invidious/InvidiousService.bs" import "pkg:/components/Services/Invidious/InvidiousToContentNode.bs" import "pkg:/source/services/HttpClient.bs" @@ -7,12 +8,15 @@ import "pkg:/source/utils/MathUtils.bs" function VideoQueueViewContentTask(input as object) as object videoNodes = input.videoNodes invidiousNode = input.invidious + preferencesNode = input.preferences service = new Invidious.InvidiousService(invidiousNode) instance = service.GetInstance() cancellation = m.top.cancellation index = MathUtils.Clamp(input.index - 2, 0, videoNodes.Count() - 1) + backend = preferencesNode["playback.backend"] + requestOptions = { cacheSeconds: 8640000 ' 100 days cancellation: cancellation @@ -24,7 +28,7 @@ function VideoQueueViewContentTask(input as object) as object ' Need to change it to load on demand using render tracking for i = index to videoNodes.Count() - 1 videoNode = videoNodes[i] - if not LoadVideoDetail(videoNode, service, instance, requestOptions) + if not LoadVideoDetail(videoNode, service, instance, requestOptions, backend) ' Sleep for a bit to avoid creating too much traffic sleep(500) end if @@ -36,7 +40,7 @@ function VideoQueueViewContentTask(input as object) as object for i = index - 1 to 0 step -1 videoNode = videoNodes[i] - if not LoadVideoDetail(videoNode, service, instance, requestOptions) + if not LoadVideoDetail(videoNode, service, instance, requestOptions, backend) ' Sleep for a bit to avoid creating too much traffic sleep(1000) end if @@ -50,9 +54,15 @@ function VideoQueueViewContentTask(input as object) as object end function ' Returns true if the video was loaded from cache. -function LoadVideoDetail(videoNode as object, service as object, instance as string, requestOptions as object) as boolean +function LoadVideoDetail(videoNode as object, service as object, instance as string, requestOptions as object, backend as string) as boolean videoId = videoNode.videoId - response = service.GetVideoMetadata(videoId, requestOptions) + + if backend = "playlet" + response = InnertubeService.GetVideoMetadata(videoId, requestOptions) + else + response = service.GetVideoMetadata(videoId, requestOptions) + end if + metadata = response.Json() if not response.IsSuccess() or metadata = invalid diff --git a/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs b/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs index 0162ba37..55e52695 100644 --- a/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs +++ b/playlet-lib/src/components/VideoQueue/VideoQueueViewUtils.bs @@ -7,6 +7,7 @@ namespace VideoQueueViewUtils videoQueueView.videoQueue = videoQueue videoQueueView.appController = appController videoQueueView.invidious = videoQueue.invidious + videoQueueView.preferences = videoQueue.preferences appController@.PushScreen(videoQueueView) content = videoQueue.content diff --git a/playlet-lib/src/config/preferences.json5 b/playlet-lib/src/config/preferences.json5 index 5d855ea3..88b394f0 100644 --- a/playlet-lib/src/config/preferences.json5 +++ b/playlet-lib/src/config/preferences.json5 @@ -32,6 +32,23 @@ }, ], }, + { + displayText: "Backend", + key: "playback.backend", + description: "The backend used for fetching video data. The Playlet backend is experimental", + type: "radio", + defaultValue: "playlet", + options: [ + { + displayText: "Invidious", + value: "invidious", + }, + { + displayText: "Playlet (local)", + value: "playlet", + }, + ], + }, ], }, { diff --git a/playlet-lib/src/source/services/HttpClient.bs b/playlet-lib/src/source/services/HttpClient.bs index 64f5975d..10816626 100644 --- a/playlet-lib/src/source/services/HttpClient.bs +++ b/playlet-lib/src/source/services/HttpClient.bs @@ -616,6 +616,14 @@ namespace HttpClient m._text = text end function + function OverrideJson(json as object) + m._json = json + end function + + function OverrideErrorMessage(errorMessage as string) + m._errorMessage = errorMessage + end function + function IsSuccess() as boolean statusCode = m.StatusCode() return statusCode >= 200 and statusCode < 400