diff --git a/playlet-app/package.json b/playlet-app/package.json index f89a3145..84159bd4 100644 --- a/playlet-app/package.json +++ b/playlet-app/package.json @@ -6,10 +6,7 @@ "bslib": "npm:@rokucommunity/bslib@^0.1.1" }, "ropm": { - "rootDir": "./src", - "noprefix": [ - "roku-requests" - ] + "rootDir": "./src" }, "scripts": { "prebuild": "rm -rf build/playlet-app", diff --git a/playlet-lib/package-lock.json b/playlet-lib/package-lock.json index be194a40..cdd942b8 100644 --- a/playlet-lib/package-lock.json +++ b/playlet-lib/package-lock.json @@ -10,8 +10,7 @@ "hasInstallScript": true, "dependencies": { "bslib": "npm:@rokucommunity/bslib@^0.1.1", - "log": "npm:roku-log@^0.9.3", - "roku-requests": "^1.2.0" + "log": "npm:roku-log@^0.9.3" } }, "node_modules/bslib": { @@ -28,11 +27,6 @@ "dependencies": { "bslib": "npm:@rokucommunity/bslib@^0.1.1" } - }, - "node_modules/roku-requests": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/roku-requests/-/roku-requests-1.2.0.tgz", - "integrity": "sha512-X6XakmJwxT8H+YNvjZOwH+k8rmYpD5o47k37WyZJ/brrSD6cw9xbNdxO203nz6jpso4maQJGwSJvLGZpzSQ0AA==" } }, "dependencies": { @@ -48,11 +42,6 @@ "requires": { "bslib": "npm:@rokucommunity/bslib@^0.1.1" } - }, - "roku-requests": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/roku-requests/-/roku-requests-1.2.0.tgz", - "integrity": "sha512-X6XakmJwxT8H+YNvjZOwH+k8rmYpD5o47k37WyZJ/brrSD6cw9xbNdxO203nz6jpso4maQJGwSJvLGZpzSQ0AA==" } } } diff --git a/playlet-lib/package.json b/playlet-lib/package.json index cffafeff..00e8f0b5 100644 --- a/playlet-lib/package.json +++ b/playlet-lib/package.json @@ -4,14 +4,10 @@ "description": "Unofficial Youtube client for Roku", "dependencies": { "bslib": "npm:@rokucommunity/bslib@^0.1.1", - "log": "npm:roku-log@^0.9.3", - "roku-requests": "^1.2.0" + "log": "npm:roku-log@^0.9.3" }, "ropm": { - "rootDir": "./src", - "noprefix": [ - "roku-requests" - ] + "rootDir": "./src" }, "scripts": { "prebuild": "rm -rf build/playlet-lib", diff --git a/playlet-lib/src/components/Screens/HomeScreen/HomeScreenRowContentTask.bs b/playlet-lib/src/components/Screens/HomeScreen/HomeScreenRowContentTask.bs index f6306ddd..53d761bc 100644 --- a/playlet-lib/src/components/Screens/HomeScreen/HomeScreenRowContentTask.bs +++ b/playlet-lib/src/components/Screens/HomeScreen/HomeScreenRowContentTask.bs @@ -9,6 +9,7 @@ function HomeScreenRowContentTask(input as object) as boolean service = new Invidious.InvidiousService(invidiousNode) response = service.MakeRequest(contentNode.feed) + ' TODO: handle cancellation ' TODO: handle unauthenticated requests if response = invalid contentNode.loadState = "failed" diff --git a/playlet-lib/src/components/Screens/SearchScreen/SearchScreen.bs b/playlet-lib/src/components/Screens/SearchScreen/SearchScreen.bs index c5cf62e1..d8c93862 100644 --- a/playlet-lib/src/components/Screens/SearchScreen/SearchScreen.bs +++ b/playlet-lib/src/components/Screens/SearchScreen/SearchScreen.bs @@ -76,7 +76,7 @@ function OnTextChange() as void end if if m.searchSuggestionsTask <> invalid - m.searchSuggestionsTask.control = "stop" + m.searchSuggestionsTask.cancel = true end if m.searchSuggestionsTask = StartAsyncTask(SearchSuggestionsTask, { @@ -86,6 +86,18 @@ function OnTextChange() as void end function function OnSearchSuggestionsTaskResults(output as object) as void + if m.searchSuggestionsTask <> invalid and m.searchSuggestionsTask.id = output.task.id + m.searchSuggestionsTask = invalid + end if + + if output.cancelled + return + end if + + if not output.success + m.log.error(output.error) + return + end if ' In case this is an old request, discard suggestions q = output.result.q if q <> m.keyboard.text @@ -137,7 +149,7 @@ end function function Search(text as string) if m.searchTask <> invalid - m.searchTask.control = "stop" + m.searchTask.cancel = "true" end if ShowLoadingScreen() @@ -147,7 +159,23 @@ function Search(text as string) }, OnSearchTaskResults) end function -function OnSearchTaskResults(output as object) +function OnSearchTaskResults(output as object) as void + if m.searchTask <> invalid and m.searchTask.id = output.task.id + m.searchTask = invalid + end if + + if output.cancelled + HideLoadingScreen() + return + end if + + ' TODO: Handle errors + if not output.success + m.log.error(output.error) + HideLoadingScreen() + return + end if + m.rowList.content = output.result.content m.rowlist.focusable = true NodeSetFocus(m.rowlist, true) diff --git a/playlet-lib/src/components/Screens/SearchScreen/SearchSuggestionsTask.bs b/playlet-lib/src/components/Screens/SearchScreen/SearchSuggestionsTask.bs index a65c7735..06c4522b 100644 --- a/playlet-lib/src/components/Screens/SearchScreen/SearchSuggestionsTask.bs +++ b/playlet-lib/src/components/Screens/SearchScreen/SearchSuggestionsTask.bs @@ -8,7 +8,15 @@ function SearchSuggestionsTask(input as object) as object service = new Invidious.InvidiousService(invidiousNode) - searchSuggestsions = q <> "" ? service.SearchSuggestions(q) : invalid + if m.top.cancel + return invalid + end if + + searchSuggestsions = StringUtils.IsNullOrEmpty(q) ? invalid : service.SearchSuggestions(q, m.top.cancellation) + + if m.top.cancel + return invalid + end if history = SearchHistory.GetSaved(q) diff --git a/playlet-lib/src/components/Screens/SearchScreen/SearchTask.bs b/playlet-lib/src/components/Screens/SearchScreen/SearchTask.bs index 5afde79b..f7f15680 100644 --- a/playlet-lib/src/components/Screens/SearchScreen/SearchTask.bs +++ b/playlet-lib/src/components/Screens/SearchScreen/SearchTask.bs @@ -10,7 +10,7 @@ function SearchTask(input as object) as object service = new Invidious.InvidiousService(invidiousNode) - response = service.Search(q, { type: "video" }) 'video,playlist,channel + response = service.Search(q, { type: "video" }, m.top.cancellation) 'video,playlist,channel instance = service.GetInstance() rowContent = GetCategoryContent(contentNode, `Search - ${q}`, response, instance) diff --git a/playlet-lib/src/components/Services/ApplicationInfo/ApplicationInfo.bs b/playlet-lib/src/components/Services/ApplicationInfo/ApplicationInfo.bs index 7393847d..de522894 100644 --- a/playlet-lib/src/components/Services/ApplicationInfo/ApplicationInfo.bs +++ b/playlet-lib/src/components/Services/ApplicationInfo/ApplicationInfo.bs @@ -1,4 +1,3 @@ -import "pkg:/source/roku_modules/rokurequests/Requests.brs" import "pkg:/source/asyncTask/asyncTask.bs" import "LatestLibVersionTask.bs" diff --git a/playlet-lib/src/components/Services/ApplicationInfo/LatestLibVersionTask.bs b/playlet-lib/src/components/Services/ApplicationInfo/LatestLibVersionTask.bs index c08ca2ab..48e47377 100644 --- a/playlet-lib/src/components/Services/ApplicationInfo/LatestLibVersionTask.bs +++ b/playlet-lib/src/components/Services/ApplicationInfo/LatestLibVersionTask.bs @@ -1,4 +1,4 @@ -import "pkg:/source/roku_modules/rokurequests/Requests.brs" +import "pkg:/source/services/HttpClient.bs" @asynctask function LatestLibVersionTask() as object @@ -9,15 +9,13 @@ function LatestLibVersionTask() as object end function function GetLatestPlayletLibVersionFromGithubReleases() as string - args = { - parseJson: false - } - response = Requests().request("HEAD", "https://github.com/iBicha/playlet/releases/latest", args) + response = HttpClient.Head("https://github.com/iBicha/playlet/releases/latest").Await() - if response.statusCode = 200 - if response.headers.location <> invalid + if response.StatusCode() = 200 + headers = response.Headers() + if headers.location <> invalid regex = CreateObject("roRegex", "/v?(\d+\.\d+\.\d+)", "") - match = regex.match(response.headers.location) + match = regex.match(headers.location) if match.Count() = 2 return match[1] end if diff --git a/playlet-lib/src/components/Services/Invidious/InvidiousService.bs b/playlet-lib/src/components/Services/Invidious/InvidiousService.bs index fa1743b7..0b362300 100644 --- a/playlet-lib/src/components/Services/Invidious/InvidiousService.bs +++ b/playlet-lib/src/components/Services/Invidious/InvidiousService.bs @@ -1,8 +1,8 @@ -import "pkg:/source/roku_modules/rokurequests/Requests.brs" import "pkg:/source/utils/RegistryUtils.bs" import "pkg:/source/utils/StringUtils.bs" import "pkg:/source/utils/TimeUtils.bs" import "pkg:/source/utils/CryptoUtils.bs" +import "pkg:/source/services/HttpClient.bs" namespace Invidious const DEFAULT_INSTANCE = "https://vid.puffyan.us" @@ -41,11 +41,9 @@ namespace Invidious } end function - function DefaultArgs() as object + function DefaultQueryParams() as object return { - params: { - region: m.node.applicationInfo@.GetUserCountryCode(invalid) - } + region: m.node.applicationInfo@.GetUserCountryCode(invalid) } end function @@ -64,67 +62,50 @@ namespace Invidious return DEFAULT_INSTANCE end function - function SearchSuggestions(q as string) as object + function SearchSuggestions(q as string, cancellation = invalid as object) as object instance = m.GetInstance() url = `${instance}${Invidious.SEARCH_SUGGEST_ENDPOINT}` - args = m.DefaultArgs() - args.params.q = q - args.cacheSeconds = 60 * 60 * 6 ' 6 hours + request = HttpClient.Get(url) + request.QueryParams(m.DefaultQueryParams()) + request.QueryParam("q", q) + request.CacheSeconds(60 * 60 * 6)' 6 hours - response = Requests().get(url, args) + request.Cancellation(cancellation) - if response.statuscode = 200 - return response.json - end if - return invalid + response = request.Await() + ' TODO: return error if status code is not 200 + return response.Json() end function - function Search(q as string, args = invalid as dynamic) as object + function Search(q as string, args = invalid as dynamic, cancellation = invalid as object) as object instance = m.GetInstance() url = `${instance}${Invidious.SEARCH_ENDPOINT}` - _args = m.DefaultArgs() - _args.params.q = q + request = HttpClient.Get(url) + request.QueryParams(m.DefaultQueryParams()) + request.QueryParam("q", q) - if args <> invalid - if args.page <> invalid - _args.params.page = Str(args.page).Trim() - end if - if args.sort_by <> invalid - _args.params.sort_by = args.sort_by - end if - if args.date <> invalid - _args.params.date = args.date - end if - if args.duration <> invalid - _args.params.duration = args.duration - end if - if args.type <> invalid - _args.params.type = args.type - end if - if args.features <> invalid - _args.params.features = args.features.join(",") - end if + if IsArray(args.features) + args.features = args.features.join(",") end if - response = Requests().get(url, _args) + request.QueryParams(args) - if response.statuscode = 200 - return response.json - end if - return invalid + request.Cancellation(cancellation) + + response = request.Await() + return response.Json() end function function GetVideoMetadata(videoId as string) as object instance = m.GetInstance() url = `${instance}${Invidious.VIDEOS_ENDPOINT}/${videoId}` - args = m.DefaultArgs() - args.cacheSeconds = 60 * 60 * 6 ' 6 hours - - response = Requests().get(url, args) + request = HttpClient.Get(url) + request.QueryParams(m.DefaultQueryParams()) + request.CacheSeconds(60 * 60 * 6)' 6 hours - return response + return request.Await() end function function GetVideoStreamUrl(videoId as string) as string @@ -140,17 +121,15 @@ namespace Invidious instance = m.GetInstance() - url = instance + endpoint.url - - args = { params: {} } + request = HttpClient.Get(instance + endpoint.url) if endpoint.authenticated = true authToken = m.node.authToken if authToken = invalid return invalid end if - url = authToken.instance + endpoint.url - args.headers = m.GetAuthenticationHeaders(authToken.token) + request.Url(authToken.instance + endpoint.url) + request.Headers(m.GetAuthenticationHeaders(authToken.token)) end if if endpoint.queryParams <> invalid @@ -158,12 +137,12 @@ namespace Invidious queryParam = endpoint.queryParams[queryParamKey] if queryParam.default <> invalid if queryParam.type = "string" - args.params[queryParamKey] = queryParam.default + request.QueryParam(queryParamKey, queryParam.default) else if queryParam.type = "#ISO3166" if queryParam.default = "GetUserCountryCode" - args.params[queryParamKey] = m.node.applicationInfo@.GetUserCountryCode(invalid) + request.QueryParam(queryParamKey, m.node.applicationInfo@.GetUserCountryCode(invalid)) else - args.params[queryParamKey] = queryParam.default + request.QueryParam(queryParamKey, queryParam.default) end if end if end if @@ -171,17 +150,15 @@ namespace Invidious end if if requestData.queryParams <> invalid - args.params.append(requestData.queryParams) + request.QueryParams(requestData.queryParams) end if if requestData.pathParams <> invalid - for each param in requestData.pathParams - url = url.Replace(`:${param}`, requestData.pathParams[param]) - end for + request.PathParams(requestData.pathParams) end if ' TODO: error handling - response = Requests().get(url, args) + response = request.Await() responseHandler = endpoint.responseHandler <> invalid ? m.responseHanlders[endpoint.responseHandler] : m.responseHanlders["DefaultHandler"] @@ -203,26 +180,24 @@ namespace Invidious end function function DefaultHandler(m as object, requestData as object, response as object) as object - if response.statuscode = 200 - return response.json - end if - return invalid + return response.Json() end function function AuthFeedHandler(m as object, requestData as object, response as object) as object m.DeleteExpiredToken(response) - if response.statuscode = 200 + if response.StatusCode() = 200 + json = response.Json() videos = [] - videos.Append(response.json.notifications) - videos.Append(response.json.videos) + videos.Append(json.notifications) + videos.Append(json.videos) return videos end if return invalid end function function DeleteExpiredToken(response as object) - if response.statuscode = 403 + if response.StatusCode() = 403 m.DeleteAuthToken() end if end function @@ -277,21 +252,14 @@ namespace Invidious return true end function - function UnregisterToken(authToken as object) as boolean - headers = m.GetAuthenticationHeaders(authToken.token) - + function UnregisterToken(authToken as object) as void url = `${authToken.instance}${Invidious.AUTH_TOKENS_UNREGISTER}` - ' authToken.token is already valid json that contains the "session" key needed by the unregister endpoint - headers["Content-Type"] = "application/json" - args = { - headers: headers, - data: authToken.token, - parseJson: false - } + request = HttpClient.Post(url, authToken.token) + request.Headers(m.GetAuthenticationHeaders(authToken.token)) + request.Header("Content-Type", "application/json") - response = Requests().post(url, args) - return response.ok + request.SendAndForget() end function function GetAuthorizeTokenLink() as dynamic diff --git a/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs b/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs index dfd1bf0c..ff22e28e 100644 --- a/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs +++ b/playlet-lib/src/components/VideoPlayer/VideoContentTask.bs @@ -15,15 +15,15 @@ function VideoContentTask(input as object) as object response = service.GetVideoMetadata(contentNode.videoId) - if not response.ok + metadata = response.Json() + + if not response.IsSuccess() or metadata = invalid return { success: false, - response: response + error: response.ErrorMessage() } end if - metadata = response.json - contentNode.title = metadata.title contentNode.secondaryTitle = metadata.author diff --git a/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs b/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs index 8a45535f..c77f13fe 100644 --- a/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs +++ b/playlet-lib/src/components/VideoPlayer/VideoPlayer.bs @@ -62,12 +62,14 @@ function OnVideoContentTaskResults(output as object) as void m.top.control = "play" if ValidInt(m.top.content.timestamp) > 0 + ' TODO: handle seeking in the case where the first stream url failed m.top.seek = m.top.content.timestamp end if end function function Close(unused as dynamic) m.top.control = "stop" + m.top.content = invalid parent = m.top.getParent() if parent <> invalid parent.RemoveChild(m.top) diff --git a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/InvidiousRouter.bs b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/InvidiousRouter.bs index 827f7ed7..ca70faec 100644 --- a/playlet-lib/src/components/Web/PlayletWebServer/Middleware/InvidiousRouter.bs +++ b/playlet-lib/src/components/Web/PlayletWebServer/Middleware/InvidiousRouter.bs @@ -1,4 +1,3 @@ -import "pkg:/source/roku_modules/rokurequests/Requests.brs" import "pkg:/components/Services/Invidious/InvidiousService.bs" import "pkg:/source/utils/CryptoUtils.bs" diff --git a/playlet-lib/src/config/invidious_video_api.json5 b/playlet-lib/src/config/invidious_video_api.json5 index 4e99760f..4d2116db 100644 --- a/playlet-lib/src/config/invidious_video_api.json5 +++ b/playlet-lib/src/config/invidious_video_api.json5 @@ -101,19 +101,19 @@ { name: "playlist", displayName: "Playlist", - url: "/api/v1/playlists/:plid", + url: "/api/v1/playlists/{plid}", responseHandler: "PlaylistHandler", }, { name: "mix", displayName: "Mix", - url: "/api/v1/mixes/:rdid", + url: "/api/v1/mixes/{rdid}", responseHandler: "PlaylistHandler", }, { name: "channel", displayName: "Channel", - url: "/api/v1/channels/videos/:ucid", + url: "/api/v1/channels/videos/{ucid}", queryParams: { sort_by: { required: false, diff --git a/playlet-lib/src/source/asyncTask/asyncTask.bs b/playlet-lib/src/source/asyncTask/asyncTask.bs index 7e848a47..6b9b22c3 100644 --- a/playlet-lib/src/source/asyncTask/asyncTask.bs +++ b/playlet-lib/src/source/asyncTask/asyncTask.bs @@ -38,8 +38,6 @@ function OnTaskState(e as object) as void if output <> invalid callback = m[`asynctask_${id}_callback`] if callback <> invalid - output = task.output - output.task = task callback(output) end if end if diff --git a/playlet-lib/src/source/services/HttpClient.bs b/playlet-lib/src/source/services/HttpClient.bs new file mode 100644 index 00000000..9f506dda --- /dev/null +++ b/playlet-lib/src/source/services/HttpClient.bs @@ -0,0 +1,601 @@ +import "pkg:/source/roku_modules/log/LogMixin.brs" +import "pkg:/source/utils/CryptoUtils.bs" + +namespace HttpClient + + function Get(url as string) as object + return (new HttpRequest()).Method("GET").Url(url) + end function + + function Post(url as string, data as string) as object + return (new HttpRequest()).Method("POST").Url(url).Body(data) + end function + + function PostJson(url as string, data as object) as object + return (new HttpRequest()).Method("POST").Url(url).Json(data) + end function + + function Put(url as string, data as string) as object + return (new HttpRequest()).Method("PUT").Url(url).Body(data) + end function + + function PutJson(url as string, data as object) as object + return (new HttpRequest()).Method("PUT").Url(url).Json(data) + end function + + function Delete(url as string) as object + return (new HttpRequest()).Method("DELETE").Url(url) + end function + + function Head(url as string) as object + return (new HttpRequest()).Method("HEAD").Url(url) + end function + + class HttpRequest + + public urlTransfer as object + + function new() + m.log = new log.Logger("HttpRequest") + m._timeoutMs = 30000 + end function + + function Method(method as string) as object + m._method = Ucase(method) + return m + end function + + function Url(url as string) as object + m._url = url + return m + end function + + function QueryParam(key as string, value as dynamic) as object + if value = invalid + return m + end if + if m._queryParams = invalid + m._queryParams = {} + end if + m._queryParams[key] = value + return m + end function + + function QueryParams(queryParams as object) as object + if m._queryParams = invalid + m._queryParams = {} + end if + m._queryParams.append(queryParams) + return m + end function + + function PathParam(key as string, value as string) as object + if m._pathParams = invalid + m._pathParams = {} + end if + m._pathParams[key] = value + return m + end function + + function PathParams(pathParams as object) as object + if m._pathParams = invalid + m._pathParams = {} + end if + m._pathParams.append(pathParams) + return m + end function + + function Header(key as string, value as string) as object + if m._headers = invalid + m._headers = {} + end if + m._headers[key] = value + return m + end function + + function Headers(headers as object) as object + if m._headers = invalid + m._headers = {} + end if + m._headers.append(headers) + return m + end function + + function Body(body as string) as object + m._body = body + return m + end function + + function Json(body as object) as object + m._body = FormatJson(body) + m.Header("Content-Type", "application/json") + return m + end function + + function Timeout(timeoutMs as integer) as object + m._timeoutMs = timeoutMs + return m + end function + + function NoCache() as object + m._noCache = true + return m + end function + + function CacheSeconds(expireSeconds as integer) as object + m._expireSeconds = expireSeconds + return m + end function + + function Cancellation(cancellation as object) as object + m._cancellation = cancellation + return m + end function + + function ToCurlCommand() as string + command = `curl -X ${m._method}` + + if m._headers <> invalid + for each key in m._headers + command += ` -H '${key}: ${m._headers[key]}'` + end for + end if + + if not StringUtils.IsNullOrEmpty(m._body) + command += ` -d '${m._body}'` + end if + + timeoutSeconds = m._timeoutMs / 1000 + command += ` --max-time ${timeoutSeconds}` + + command += ` "${m.BuildUrl()}"` + + return command + end function + + function Send() as object + return m.DoSend(false) + end function + + function SendAndForget() as object + return m.DoSend(true) + end function + + private function DoSend(forget as boolean) as object + if m.urlTransfer <> invalid or m._cache <> invalid + return m + end if + + if m._noCache <> true + cache = HttpClientCache.Get(m) + if cache <> invalid + m.log.info("Cache hit", m._method, m.BuildUrl()) + m._cache = cache + return m + end if + end if + + m.urlTransfer = m.CreateRoUrlTransfer() + m.urlTransfer.setUrl(m.BuildUrl()) + if m._headers <> invalid + m.urlTransfer.SetHeaders(m._headers) + end if + + if not forget + m.urlTransfer.SetMessagePort(CreateObject("roMessagePort")) + end if + + m.log.info("ID:", m.urlTransfer.GetIdentity(), "Sending", m._method, m.urlTransfer.GetURL()) + + #if DEBUG + ? "---- curl command ----" + ? m.ToCurlCommand() + ? "----------------------" + #end if + + if m._method = "POST" + m._sent = m.urlTransfer.AsyncPostFromString(m._data) + else if m._method = "GET" + m._sent = m.urlTransfer.AsyncGetToString() + else if m._method = "HEAD" + m._sent = m.urlTransfer.AsyncHead() + else + m.urlTransfer.SetRequest(m._method) + m._sent = m.urlTransfer.AsyncPostFromString(m._data) + end if + + if m._sent <> true + m.log.error("Failed to send request") + end if + + return m + end function + + function IsCancelled() as boolean + if m._cancelled = true + return m._cancelled + end if + + if m._cancellation = invalid or m._cancellation.node = invalid or m._cancellation.field = invalid + return false + end if + + if m._cancellation.node[m._cancellation.field] = m._cancellation.value + m._cancelled = true + end if + + return m._cancelled = true + end function + + function Await() as object + if m.urlTransfer = invalid and m._cache = invalid + m.Send() + end if + + if m._sent <> true or m._cache <> invalid + return new HttpResponse(m, invalid) + end if + + if m.IsCancelled() + return new HttpResponse(m, invalid) + end if + + messagePort = m.urlTransfer.GetMessagePort() + if messagePort = invalid + throw "Can't await request without a message port. use Send(andForget=false) in order to await request." + end if + + if m._cancellation <> invalid + if m._cancellation.node <> invalid and m._cancellation.field <> invalid + m._cancellation.node.ObserveFieldScoped(m._cancellation.field, messagePort) + end if + end if + + msg = wait(m._timeoutMs, messagePort) + + if m._cancellation <> invalid + if m._cancellation.node <> invalid and m._cancellation.field <> invalid + m._cancellation.node.UnobserveFieldScoped(m._cancellation.field) + end if + end if + + if msg = invalid + ' timeout + m.log.info("ID:", m.urlTransfer.GetIdentity(), "Timeout") + m.urlTransfer.AsyncCancel() + else + eventType = type(msg) + if eventType = "roUrlEvent" + m.log.info("ID:", m.urlTransfer.GetIdentity(), "Finished") + else if eventType = "roSGNodeEvent" + node = msg.getRoSGNode() + field = msg.getField() + value = msg.getData() + ' cancellation + if node.isSameNode(m._cancellation.node) and field = m._cancellation.field and value = m._cancellation.value + m.log.info("ID:", m.urlTransfer.GetIdentity(), "Cancelled") + m._cancelled = true + m.urlTransfer.AsyncCancel() + end if + end if + end if + + response = new HttpResponse(m, msg) + + if m.IsCancelled() + return response + end if + + HttpClientCache.Set(response) + return response + end function + + private function CreateRoUrlTransfer() as object + urlTransfer = CreateObject("roUrlTransfer") + urlTransfer.EnableEncodings(true) + urlTransfer.RetainBodyOnError(true) + if LCase(left(m._url, 6)).StartsWith("https:") + urlTransfer.SetCertificatesFile("common:/certs/ca-bundle.crt") + urlTransfer.InitClientCertificates() + end if + return urlTransfer + end function + + function BuildUrl() as string + if m._fullUrl <> invalid + return m._fullUrl + end if + + url = m._url + if m._pathParams <> invalid + for each key in m._pathParams + value = m._pathParams[key] + if value = invalid + continue for + end if + if not IsString(value) + value = `${value}` + end if + + url = url.Replace(`{${key}}`, value.EncodeUriComponent()) + end for + end if + if m._queryParams <> invalid + hasQueryParams = url.InStr("?") <> -1 + for each key in m._queryParams + value = m._queryParams[key] + if value = invalid + continue for + end if + if not IsString(value) + value = `${value}` + end if + if hasQueryParams + url += "&" + key.EncodeUriComponent() + "=" + value.EncodeUriComponent() + else + url += "?" + key.EncodeUriComponent() + "=" + value.EncodeUriComponent() + hasQueryParams = true + end if + end for + end if + m._fullUrl = url + return m._fullUrl + end function + end class + + class HttpResponse + + public request as HttpRequest + public event as object + + function new(request as HttpRequest, event as object) + m.request = request + m.event = event + end function + + function StatusCode() as integer + if m._statusCode <> invalid + return m._statusCode + end if + + if m.IsCached() + m._statusCode = m.request._cache.statusCode + return m._statusCode + end if + + if type(m.event) <> "roUrlEvent" + m._statusCode = 0 + else + m._statusCode = m.event.GetResponseCode() + end if + + return m._statusCode + end function + + function IsSuccess() as boolean + statusCode = m.StatusCode() + return statusCode >= 200 and statusCode < 400 + end function + + function IsCached() as boolean + return m.request._cache <> invalid + end function + + function TimedOut() as boolean + return not m.IsCached() and m.event = invalid + end function + + function IsCancelled() as boolean + return m.request.IsCancelled() + end function + + function Text() as dynamic + if m._text <> invalid + return m._text + end if + + if m.IsCached() + m._text = m.request._cache.body + return m._text + end if + + if not m.IsSuccess() + return invalid + end if + m._text = m.event.GetString() + return m._text + end function + + function Json() as object + if m._json <> invalid + return m._json + end if + + text = m.Text() + if text = invalid + return invalid + end if + m._json = ParseJson(text) + return m._json + end function + + function Headers() as object + if m._headers <> invalid + return m._headers + end if + + if m.IsCached() + m._headers = m.request._cache.headers + return m._headers + end if + + if type(m.event) = "roUrlEvent" + m._headers = m.event.GetResponseHeaders() + else + m._headers = {} + end if + return m._headers + end function + + function ErrorMessage() as string + if m._errorMessage <> invalid + return m._errorMessage + end if + + if m.IsSuccess() + m._errorMessage = "" + else + if m.request_sent <> true + m._errorMessage = "Request not sent." + else if m.TimedOut() + m._errorMessage = "Request timed out." + else if m.IsCancelled() + m._errorMessage = "Request cancelled." + else if type(m.event) = "roUrlEvent" + m._errorMessage = `${m.event.GetFailureReason() }\nStatusCode: ${m.StatusCode()}\nBody: ${m.event.GetString()}` + end if + end if + + return m._errorMessage + end function + + end class + +end namespace + +namespace HttpClientCache + function GetLocation(request as HttpClient.HttpRequest) as dynamic + if request._cacheLocation <> invalid + return request._cacheLocation + end if + cacheKey = request.BuildUrl() + if request._headers <> invalid + headersString = FormatJson(request._headers) + cacheKey = cacheKey + headersString + end if + + hash = CryptoUtils.GetMd5(cacheKey) + request._cacheLocation = `cachefs:/request_v1_${hash}.json` + return request._cacheLocation + end function + + function GetFileSystem(request as HttpClient.HttpRequest) as object + if request._fileSystem = invalid + request._fileSystem = CreateObject("roFileSystem") + end if + return request._fileSystem + end function + + function Exists(request as HttpClient.HttpRequest) as boolean + fileSystem = GetFileSystem(request) + if fileSystem = invalid + return false + end if + cacheLocation = GetLocation(request) + return fileSystem.Exists(cacheLocation) + end function + + function Get(request as HttpClient.HttpRequest) as object + if request._noCache = true + return invalid + end if + + if request._method <> "GET" + return invalid + end if + + if not Exists(request) + return invalid + end if + + cacheLocation = GetLocation(request) + cacheText = ReadAsciiFile(cacheLocation) + cacheObject = ParseJson(cacheText) + if cacheObject = invalid + Delete(request) + return invalid + end if + + expireSeconds = request._expireSeconds + + if expireSeconds = invalid and cacheObject.headers <> invalid + cacheControl = cacheObject.headers["cache-control"] + if cacheControl <> invalid + cacheControlDirectives = cacheControl.split(",") + for each cacheControlDirective in cacheControlDirectives + keyValue = cacheControlDirective.Trim().split("=") + name = keyValue[0].Trim() + if keyValue.Count() > 1 + value = keyValue[1].Trim() + else + value = invalid + end if + + if name = "no-store" or name = "no-cache" + return invalid + else if name = "max-age" + expireSeconds = val(value) + exit for + end if + end for + end if + end if + + if expireSeconds = invalid + return invalid + end if + + date = CreateObject("roDateTime") + nowTimestamp = date.AsSeconds() + if cacheObject.timestamp + expireSeconds < nowTimestamp + Delete(request) + return invalid + end if + + return cacheObject + end function + + function Set(response as HttpClient.HttpResponse) as void + if response.request._noCache = true + return + end if + + if response.request._method <> "GET" + return + end if + + if not response.IsSuccess() + return + end if + + fileSystem = GetFileSystem(response.request) + if fileSystem = invalid + return + end if + + date = CreateObject("roDateTime") + timestamp = date.AsSeconds() + + cacheObject = { + timestamp: timestamp, + statusCode: response.StatusCode(), + headers: response.Headers(), + body: response.Text() + } + + cacheLocation = GetLocation(response.request) + cacheText = FormatJson(cacheObject) + WriteAsciiFile(cacheLocation, cacheText) + end function + + function Delete(request as HttpClient.HttpRequest) as boolean + fileSystem = GetFileSystem(request) + if fileSystem = invalid + return false + end if + + cacheLocation = GetLocation(request) + return fileSystem.Delete(cacheLocation) + end function + +end namespace diff --git a/playlet-lib/src/source/services/SearchHistory.bs b/playlet-lib/src/source/services/SearchHistory.bs index 7c567fae..d210c720 100644 --- a/playlet-lib/src/source/services/SearchHistory.bs +++ b/playlet-lib/src/source/services/SearchHistory.bs @@ -1,5 +1,6 @@ import "pkg:/source/utils/ArrayUtils.bs" import "pkg:/source/utils/RegistryUtils.bs" +import "pkg:/source/utils/StringUtils.bs" namespace SearchHistory function Save(q as string, maxItems = 10 as integer) @@ -26,7 +27,7 @@ namespace SearchHistory return [] end if - if q = "" + if StringUtils.IsNullOrEmpty(q) return history end if result = [] diff --git a/tools/bs-plugins/asynctask-plugin.ts b/tools/bs-plugins/asynctask-plugin.ts index 9368d56f..2dc6c5ab 100644 --- a/tools/bs-plugins/asynctask-plugin.ts +++ b/tools/bs-plugins/asynctask-plugin.ts @@ -162,6 +162,11 @@ import "pkg:/${file.pkgPath}" function Init() m.top.functionName = "TaskMain" + m.top.cancellation = { + node: m.top, + field: "cancel", + value: true + } end function function TaskMain() @@ -169,11 +174,15 @@ function TaskMain() result = ${functionName}(${(hasInput ? "m.top.input" : "")}) m.top.setField("output", { success: true, + task: m.top, + cancelled: m.top.cancel, result: result }) catch e m.top.setField("output", { success: false, + task: m.top, + cancelled: m.top.cancel, error: e }) end try @@ -189,6 +198,8 @@ end function + +