source_api_baseRequest.bs
import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/roku_modules/rr/Requests.brs"
' Functions for making requests to the API
function buildParams(params = {} as object) as string
' Take an object of parameters and construct the URL query
paramArray = []
for each field in params.items()
item = ""
if type(field.value) = "String" or type(field.value) = "roString"
item = field.key + "=" + field.value.trim().EncodeUriComponent()
else if type(field.value) = "roInteger" or type(field.value) = "roInt"
item = field.key + "=" + stri(field.value).trim()
'item = field.key + "=" + str(field.value).trim()
else if type(field.value) = "roFloat"
item = field.key + "=" + stri(int(field.value)).trim()
else if type(field.value) = "LongInteger"
item = field.key + "=" + field.value.toStr().trim()
else if type(field.value) = "roArray"
' TODO handle array params
else if type(field.value) = "roBoolean"
if field.value
item = field.key + "=true"
else
item = field.key + "=false"
end if
else if not isValid(field.value)
item = field.key + "=null"
else if isValid(field)
print "Unhandled param type: " + type(field.value)
item = field.key + "=" + field.value.EncodeUriComponent()
end if
if item <> "" then paramArray.push(item)
end for
return paramArray.join("&")
end function
function buildURL(path as string, params = {} as object) as dynamic
serverURL = getUrl()
if not isValid(serverURL) then return invalid
' Add intial '/' if path does not start with one
if path.Left(1) = "/"
fullUrl = serverURL + path
else
fullUrl = serverURL + "/" + path
end if
if params.count() > 0
fullUrl = fullUrl + "?" + buildParams(params)
end if
return fullUrl
end function
function APIRequest(url as string, params = {} as object) as dynamic
fullUrl = buildURL(url, params)
if not isValid(fullUrl) then return invalid
serverURL = m.global.server.serverUrl
' ContentNode fields return "" instead of invalid, so check both
if not isValid(serverURL) or serverURL = "" then return invalid
req = createObject("roUrlTransfer")
req.setUrl(fullUrl)
req = authRequest(req)
' SSL cert
if serverURL.left(8) = "https://"
setCertificateAuthority(req)
end if
return req
end function
function getJson(req)
' Handle case where server URL is not set or invalid
if not isValid(req) then return invalid
req.setMessagePort(CreateObject("roMessagePort"))
req.AsyncGetToString()
resp = wait(timeouts.HTTP_MS, req.GetMessagePort())
if type(resp) <> "roUrlEvent"
req.AsyncCancel()
return invalid
end if
data = resp.GetString()
if data = ""
return invalid
end if
json = ParseJson(data)
return json
end function
function postJson(req, data = "" as string)
' Handle case where server URL is not set or invalid
if not isValid(req) then return invalid
req.setMessagePort(CreateObject("roMessagePort"))
req.AddHeader("Content-Type", "application/json")
req.AsyncPostFromString(data)
resp = wait(timeouts.HTTP_MS, req.GetMessagePort())
if type(resp) <> "roUrlEvent"
req.AsyncCancel()
return invalid
end if
if resp.getString() = ""
return invalid
end if
json = ParseJson(resp.GetString())
return json
end function
function getUrl()
serverURL = m.global.server.serverUrl
' Check if URL is valid AND not empty
' ContentNode fields return "" instead of invalid, so we must check both
if not isValid(serverURL) or serverURL = "" then return invalid
' Note: serverUrl is guaranteed to be normalized by:
' - server.UpdateURL() - normalizes before storing
' - SessionDataTransformer - normalizes when loading from registry
' This avoids expensive regex parsing on every API request
return serverURL
end function
' sets the certificate authority by file path on the passed node
sub setCertificateAuthority(request as object) as void
request.setCertificatesFile("common:/certs/ca-bundle.crt")
end sub
' Takes and returns a roUrlTransfer object after adding a Jellyfin "Authorization" header
function authRequest(req as object) as object
req.AddHeader("Authorization", buildAuthHeader())
return req
end function
' Returns a string containing the "Authorization" header payload.
'
' DeviceId is the one field here that is load-bearing for session identity: Jellyfin resolves it
' from THIS header only (never from a query string), and falls back to the DeviceId the auth token
' was minted under when the header omits it. Every channel that opens a Jellyfin session must
' therefore send this header, or it silently lands on a different session — see the ws:// receiver
' in RemoteControlTask and issue #743.
'
' @param shouldIncludeDeviceName - false omits the free-text `Device=` field. The ws:// handshake is
' written as a raw string with no header-encoding layer, and a user-set Roku name may carry
' non-ASCII; the server already holds the device name on the token's device row, and omitting
' it also avoids a device-row rewrite on every reconnect. HTTP callers keep the default.
function buildAuthHeader(shouldIncludeDeviceName = true as boolean) as string
globalDevice = m.global.device
globalUser = m.global.user
QUOTE = Chr(34)
auth = "MediaBrowser" + " Client=" + QUOTE + "JellyRock" + QUOTE
if shouldIncludeDeviceName
auth = auth + ", Device=" + QUOTE + globalDevice.name + " (" + globalDevice.model + ")" + QUOTE
end if
auth = auth + ", Version=" + QUOTE + m.global.app.version + QUOTE
if isValid(globalUser.id)
auth = auth + ", UserId=" + QUOTE + globalUser.id + QUOTE
end if
auth = auth + ", DeviceId=" + QUOTE + globalDevice.serverDeviceName + QUOTE
if isValid(globalUser.authToken)
auth = auth + ", Token=" + QUOTE + globalUser.authToken + QUOTE
end if
return auth
end function
' Shared HTTP execution for the API task tiers. Builds the request args (auth header,
' timeout, Content-Type default) from a request AA and performs the roku-requests call.
' Returns the raw roku-requests result object, or invalid when the URL is missing/empty
' (the caller decides how to surface that — Tier 1 maps it to an error response AA, the
' fire-and-forget SideEffectTask ignores it).
'
' Centralizes what previously lived duplicated in ApiTask.executeRequest() and
' SideEffectTask.executeSideEffect(); that copy drifted across three separate historical
' fixes (Content-Type default, URL validation, timeout centralization), each of which had
' to patch both copies in lockstep. Runs on whichever Task thread invokes it — free
' functions share the caller's `m`, so auth/global reads resolve exactly as before.
'
' @param req - request AA: { method, url, headers?, body?, timeout? }
' @param defaultMethod - HTTP method when req.method is absent ("GET" for the pool, "POST" for side effects)
' @param logLabel - tag prefixed to the invalid-URL log line (e.g. "[ApiTask]")
' @return roku-requests result (.ok / .statusCode / .json / .text), or invalid on bad URL
function executeHttpRequest(req as object, defaultMethod as string, logLabel as string) as object
method = req.method
if not isValid(method) or method = "" then method = defaultMethod
' Auth header first; merge in any caller-provided extras
headers = { Authorization: buildAuthHeader() }
if isValid(req.headers) and type(req.headers) = "roAssociativeArray"
headers.append(req.headers)
end if
' timeout in request AA is seconds; rr_Requests expects milliseconds
timeout = timeouts.HTTP_MS
if isValid(req.timeout) then timeout = req.timeout * 1000
args = {
headers: headers,
timeout: timeout,
useCache: false
}
if isValid(req.body) and req.body <> ""
args.data = req.body
' POST/PUT/PATCH with a body requires Content-Type so the server knows the format.
' Only set if not already provided by caller-supplied headers.
if not isValid(headers["Content-Type"])
headers["Content-Type"] = "application/json"
end if
end if
if not isValid(req.url) or req.url = ""
print logLabel; " request rejected: url is invalid or empty"
return invalid
end if
return rr_Requests().request(method, req.url, args)
end function