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