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
+
+
`