jf-roku/source/utils/misc.bs

557 lines
18 KiB
Plaintext
Raw Normal View History

import "pkg:/source/utils/config.bs"
2023-09-11 01:36:40 +00:00
function isNodeEvent(msg, field as string) as boolean
2021-07-09 20:08:32 +00:00
return type(msg) = "roSGNodeEvent" and msg.getField() = field
2019-03-10 05:28:28 +00:00
end function
2019-10-15 02:43:44 +00:00
function getMsgPicker(msg, subnode = "" as string) as object
2021-07-09 20:08:32 +00:00
node = msg.getRoSGNode()
' Subnode allows for handling alias messages
if subnode <> ""
node = node.findNode(subnode)
end if
coords = node.rowItemSelected
target = node.content.getChild(coords[0]).getChild(coords[1])
return target
2019-03-10 05:28:28 +00:00
end function
2019-10-15 02:43:44 +00:00
function getButton(msg, subnode = "buttons" as string) as object
2021-07-09 20:08:32 +00:00
buttons = msg.getRoSGNode().findNode(subnode)
if buttons = invalid then return invalid
active_button = buttons.focusedChild
return active_button
end function
2019-03-19 18:27:30 +00:00
2019-03-14 03:01:05 +00:00
function leftPad(base as string, fill as string, length as integer) as string
2021-07-09 20:08:32 +00:00
while len(base) < length
base = fill + base
end while
return base
2019-03-14 03:01:05 +00:00
end function
function ticksToHuman(ticks as longinteger) as string
2021-07-09 20:08:32 +00:00
totalSeconds = int(ticks / 10000000)
hours = stri(int(totalSeconds / 3600)).trim()
minutes = stri(int((totalSeconds - (val(hours) * 3600)) / 60)).trim()
seconds = stri(totalSeconds - (val(hours) * 3600) - (val(minutes) * 60)).trim()
if val(hours) > 0 and val(minutes) < 10 then minutes = "0" + minutes
if val(seconds) < 10 then seconds = "0" + seconds
r = ""
if val(hours) > 0 then r = hours + ":"
r = r + minutes + ":" + seconds
return r
end function
function secondsToHuman(totalSeconds as integer, addLeadingMinuteZero as boolean) as string
humanTime = ""
hours = stri(int(totalSeconds / 3600)).trim()
minutes = stri(int((totalSeconds - (val(hours) * 3600)) / 60)).trim()
seconds = stri(totalSeconds - (val(hours) * 3600) - (val(minutes) * 60)).trim()
if val(hours) > 0 or addLeadingMinuteZero
if val(minutes) < 10
minutes = "0" + minutes
end if
end if
if val(seconds) < 10
seconds = "0" + seconds
end if
if val(hours) > 0
hours = hours + ":"
else
hours = ""
end if
humanTime = hours + minutes + ":" + seconds
return humanTime
end function
' Format time as 12 or 24 hour format based on system clock setting
function formatTime(time) as string
2021-07-09 20:08:32 +00:00
hours = time.getHours()
minHourDigits = 1
if m.global.device.clockFormat = "12h"
2021-07-09 20:08:32 +00:00
meridian = "AM"
if hours = 0
hours = 12
meridian = "AM"
else if hours = 12
hours = 12
meridian = "PM"
else if hours > 12
hours = hours - 12
meridian = "PM"
end if
else
' For 24hr Clock, no meridian and pad hours to 2 digits
minHourDigits = 2
meridian = ""
end if
2021-07-09 20:08:32 +00:00
return Substitute("{0}:{1} {2}", leftPad(stri(hours).trim(), "0", minHourDigits), leftPad(stri(time.getMinutes()).trim(), "0", 2), meridian)
end function
function div_ceiling(a as integer, b as integer) as integer
2021-07-09 20:08:32 +00:00
if a < b then return 1
if int(a / b) = a / b
return a / b
end if
return a / b + 1
end function
2020-03-04 02:46:26 +00:00
'Returns the item selected or -1 on backpress or other unhandled closure of dialog.
function get_dialog_result(dialog, port)
2021-07-09 20:08:32 +00:00
while dialog <> invalid
msg = wait(0, port)
if isNodeEvent(msg, "backPressed")
return -1
else if isNodeEvent(msg, "itemSelected")
return dialog.findNode("optionList").itemSelected
end if
end while
'Dialog has closed outside of this loop, return -1 for failure
return -1
2020-03-04 02:46:26 +00:00
end function
function lastFocusedChild(obj as object) as object
if isValid(obj)
if isValid(obj.focusedChild) and isValid(obj.focusedChild.focusedChild) and LCase(obj.focusedChild.focusedChild.subType()) = "tvepisodes"
if isValid(obj.focusedChild.focusedChild.lastFocus)
return obj.focusedChild.focusedChild.lastFocus
end if
end if
child = obj
for i = 0 to obj.getChildCount()
if isValid(obj.focusedChild)
child = child.focusedChild
end if
end for
return child
else
return invalid
end if
2020-03-04 02:46:26 +00:00
end function
function show_dialog(message as string, options = [], defaultSelection = 0) as integer
2021-07-09 20:08:32 +00:00
lastFocus = lastFocusedChild(m.scene)
dialog = createObject("roSGNode", "JFMessageDialog")
if options.count() then dialog.options = options
if message.len() > 0
reg = CreateObject("roFontRegistry")
font = reg.GetDefaultFont()
dialog.fontHeight = font.GetOneLineHeight()
dialog.fontWidth = font.GetOneLineWidth(message, 999999999)
dialog.message = message
end if
if defaultSelection > 0
dialog.findNode("optionList").jumpToItem = defaultSelection
end if
dialog.visible = true
m.scene.appendChild(dialog)
dialog.setFocus(true)
port = CreateObject("roMessagePort")
dialog.observeField("backPressed", port)
dialog.findNode("optionList").observeField("itemSelected", port)
result = get_dialog_result(dialog, port)
m.scene.removeChildIndex(m.scene.getChildCount() - 1)
lastFocus.setFocus(true)
return result
end function
2020-03-04 02:46:26 +00:00
function message_dialog(message = "" as string)
2021-07-09 20:08:32 +00:00
return show_dialog(message, ["OK"])
2020-03-04 02:46:26 +00:00
end function
function option_dialog(options, message = "", defaultSelection = 0) as integer
2021-07-09 20:08:32 +00:00
return show_dialog(message, options, defaultSelection)
end function
2021-07-09 10:21:24 +00:00
' take an incomplete url string and use it to make educated guesses about
' the complete url. then tests these guesses to see if it can find a jf server
' returns the url of the server it found, or an empty string
function inferServerUrl(url as string) as string
2023-09-11 01:36:40 +00:00
' if this server is already stored, just use the value directly
' the server had to get resolved in the first place to get into the registry
saved = get_setting("saved_servers")
if isValid(saved)
savedServers = ParseJson(saved)
if isValid(savedServers.lookup(url)) then return url
2021-07-09 20:08:32 +00:00
end if
2023-09-11 01:36:40 +00:00
port = CreateObject("roMessagePort")
hosts = CreateObject("roAssociativeArray")
reqs = []
candidates = urlCandidates(url)
for each endpoint in candidates
req = CreateObject("roUrlTransfer")
reqs.push(req) ' keep in scope outside of loop, else -10001
req.seturl(endpoint + "/system/info/public")
req.setMessagePort(port)
hosts.addreplace(req.getidentity().ToStr(), endpoint)
if endpoint.Left(8) = "https://"
req.setCertificatesFile("common:/certs/ca-bundle.crt")
2021-07-09 20:08:32 +00:00
end if
req.AsyncGetToString()
end for
handled = 0
timeout = CreateObject("roTimespan")
2023-10-15 23:40:09 +00:00
if hosts.count() > 0
while timeout.totalseconds() < 15
resp = wait(0, port)
if type(resp) = "roUrlEvent"
' TODO
' if response code is a 300 redirect then we should return the redirect url
' Make sure this happens or make it happen
if resp.GetResponseCode() = 200 and isJellyfinServer(resp.GetString())
selectedUrl = hosts.lookup(resp.GetSourceIdentity().ToStr())
print "Successfully inferred server URL: " selectedUrl
return selectedUrl
end if
2021-07-09 20:08:32 +00:00
end if
2023-10-15 23:40:09 +00:00
handled += 1
if handled = reqs.count()
print "inferServerUrl in utils/misc.brs failed to find a server from the string " url " but did not timeout."
return ""
end if
end while
print "inferServerUrl in utils/misc.brs failed to find a server from the string " url " because it timed out."
2021-07-09 20:08:32 +00:00
end if
2023-09-07 01:02:07 +00:00
return ""
end function
2023-09-14 22:30:41 +00:00
' this is the "educated guess" logic for inferServerUrl that generates a list of complete url's as candidates
' for the tests in inferServerUrl. takes an incomplete url as an arg and returns a list of extrapolated
' full urls.
function urlCandidates(input as string)
2023-09-10 23:22:11 +00:00
if input.endswith("/") then input = input.Left(len(input) - 1)
url = parseUrl(input)
if url[1] = invalid
' a proto wasn't declared
url = parseUrl("none://" + input)
2021-07-09 10:21:24 +00:00
end if
2023-10-15 23:40:09 +00:00
' if the proto is still invalid then the string is not valid
if url[1] = invalid then return []
proto = url[1]
host = url[2]
port = url[3]
path = url[4]
protoCandidates = []
supportedProtos = ["http:", "https:"] ' appending colons because the regex does
if proto = "none:" ' the user did not declare a protocol
' try every supported proto
for each supportedProto in supportedProtos
protoCandidates.push(supportedProto + "//" + host)
end for
else
protoCandidates.push(proto + "//" + host) ' but still allow arbitrary protocols if they are declared
2021-07-09 20:08:32 +00:00
end if
2023-09-13 14:52:44 +00:00
finalCandidates = []
if isValid(port) and port <> "" ' if the port is defined just use that
for each candidate in protoCandidates
2023-09-13 14:52:44 +00:00
finalCandidates.push(candidate + port + path)
end for
else ' the port wasnt declared so use default jellyfin and proto ports
for each candidate in protoCandidates:
2023-09-11 01:36:40 +00:00
' proto default
2023-09-13 14:52:44 +00:00
finalCandidates.push(candidate + path)
2023-09-11 01:36:40 +00:00
' jellyfin defaults
if candidate.startswith("https")
2023-09-13 14:52:44 +00:00
finalCandidates.push(candidate + ":8920" + path)
else if candidate.startswith("http")
2023-09-13 14:52:44 +00:00
finalCandidates.push(candidate + ":8096" + path)
end if
end for
2021-07-09 10:21:24 +00:00
end if
2023-09-13 14:52:44 +00:00
return finalCandidates
2021-07-09 10:21:24 +00:00
end function
2022-05-21 20:45:01 +00:00
sub setFieldTextValue(field, value)
node = m.top.findNode(field)
if node = invalid or value = invalid then return
' Handle non strings... Which _shouldn't_ happen, but hey
if type(value) = "roInt" or type(value) = "Integer"
value = str(value).trim()
else if type(value) = "roFloat" or type(value) = "Float"
value = str(value).trim()
else if type(value) <> "roString" and type(value) <> "String"
value = ""
end if
node.text = value
end sub
' Returns whether or not passed value is valid
2023-04-22 13:03:44 +00:00
function isValid(input as dynamic) as boolean
2022-05-21 20:45:01 +00:00
return input <> invalid
end function
' Returns whether or not all items in passed array are valid
function isAllValid(input as object) as boolean
for each item in input
if not isValid(item) then return false
end for
return true
end function
2022-12-29 00:50:50 +00:00
' Returns whether or not passed value is valid and not empty
' Accepts a string, or any countable type (arrays and lists)
2023-04-22 13:03:44 +00:00
function isValidAndNotEmpty(input as dynamic) as boolean
2022-12-29 00:50:50 +00:00
if not isValid(input) then return false
' Use roAssociativeArray instead of list so we get access to the doesExist() method
countableTypes = { "array": 1, "list": 1, "roarray": 1, "roassociativearray": 1, "rolist": 1 }
inputType = LCase(type(input))
if inputType = "string" or inputType = "rostring"
2022-12-29 00:50:50 +00:00
trimmedInput = input.trim()
return trimmedInput <> ""
2023-11-13 14:16:00 +00:00
else if inputType = "rosgnode"
inputId = input.id
return inputId <> invalid
2022-12-29 00:50:50 +00:00
else if countableTypes.doesExist(inputType)
return input.count() > 0
else
print "Called isValidAndNotEmpty() with invalid type: ", inputType
return false
end if
end function
' Returns an array from a url = [ url, proto, host, port, subdir+params ]
' If port or subdir are not found, an empty string will be added to the array
' Proto must be declared or array will be empty
2023-05-22 04:31:35 +00:00
function parseUrl(url as string) as object
rgx = CreateObject("roRegex", "^(.*:)//([A-Za-z0-9\-\.]+)(:[0-9]+)?(.*)$", "")
return rgx.Match(url)
end function
2023-05-27 03:25:06 +00:00
' Returns true if the string is a loopback, such as 'localhost' or '127.0.0.1'
2023-05-22 04:31:35 +00:00
function isLocalhost(url as string) as boolean
' https://stackoverflow.com/questions/8426171/what-regex-will-match-all-loopback-addresses
rgx = CreateObject("roRegex", "^localhost$|^127(?:\.[0-9]+){0,2}\.[0-9]+$|^(?:0*\:)*?:?0*1$", "i")
return rgx.isMatch(url)
end function
2022-05-21 20:45:01 +00:00
' Rounds number to nearest integer
function roundNumber(f as float) as integer
' BrightScript only has a "floor" round
' This compares floor to floor + 1 to find which is closer
m = int(f)
n = m + 1
x = abs(f - m)
y = abs(f - n)
if y > x
return m
else
return n
end if
end function
' Converts ticks to minutes
function getMinutes(ticks) as integer
' A tick is .1ms, so 1/10,000,000 for ticks to seconds,
' then 1/60 for seconds to minutes... 1/600,000,000
return roundNumber(ticks / 600000000.0)
2022-06-01 23:34:04 +00:00
end function
2022-06-01 23:27:59 +00:00
2022-06-01 05:05:54 +00:00
'
' Returns whether or not a version number (e.g. 10.7.7) is greater or equal
' to some minimum version allowed (e.g. 10.8.0)
2022-06-01 05:05:54 +00:00
function versionChecker(versionToCheck as string, minVersionAccepted as string)
leftHand = CreateObject("roLongInteger")
rightHand = CreateObject("roLongInteger")
regEx = CreateObject("roRegex", "\.", "")
version = regEx.Split(versionToCheck)
if version.Count() < 3
for i = version.Count() to 3 step 1
version.AddTail("0")
end for
end if
minVersion = regEx.Split(minVersionAccepted)
if minVersion.Count() < 3
for i = minVersion.Count() to 3 step 1
minVersion.AddTail("0")
end for
end if
leftHand = (version[0].ToInt() * 10000) + (version[1].ToInt() * 100) + (version[2].ToInt() * 10)
rightHand = (minVersion[0].ToInt() * 10000) + (minVersion[1].ToInt() * 100) + (minVersion[2].ToInt() * 10)
return leftHand >= rightHand
2022-05-31 20:27:26 +00:00
end function
2022-07-16 02:28:59 +00:00
function findNodeBySubtype(node, subtype)
foundNodes = []
for each child in node.getChildren(-1, 0)
if lcase(child.subtype()) = "group"
return findNodeBySubtype(child, subtype)
end if
if lcase(child.subtype()) = lcase(subtype)
foundNodes.push({
node: child,
parent: node
})
end if
end for
return foundNodes
end function
2023-02-04 05:26:55 +00:00
function AssocArrayEqual(Array1 as object, Array2 as object) as boolean
if not isValid(Array1) or not isValid(Array2)
return false
end if
if not Array1.Count() = Array2.Count()
return false
end if
for each key in Array1
if not Array2.DoesExist(key)
return false
end if
if Array1[key] <> Array2[key]
return false
end if
end for
return true
end function
' Search string array for search value. Return if it's found
function inArray(haystack, needle) as boolean
valueToFind = needle
if LCase(type(valueToFind)) <> "rostring" and LCase(type(valueToFind)) <> "string"
valueToFind = str(needle)
end if
valueToFind = lcase(valueToFind)
for each item in haystack
if lcase(item) = valueToFind then return true
end for
return false
end function
function toString(input) as string
if LCase(type(input)) = "rostring" or LCase(type(input)) = "string"
return input
end if
return str(input)
end function
'
' startLoadingSpinner: Start a loading spinner and attach it to the main JFScene.
2023-11-22 14:06:13 +00:00
' Displays an invisible ProgressDialog node by default to disable keypresses while loading.
'
' @param {boolean} [disableRemote=true]
sub startLoadingSpinner(disableRemote = true as boolean)
if not isValid(m.scene)
m.scene = m.top.getScene()
end if
if not m.scene.isLoading
m.scene.disableRemote = disableRemote
m.scene.isLoading = true
end if
2023-02-04 05:26:55 +00:00
end sub
2023-02-04 06:15:40 +00:00
sub stopLoadingSpinner()
if not isValid(m.scene)
m.scene = m.top.getScene()
2023-02-04 05:26:55 +00:00
end if
if m.scene.isLoading
m.scene.disableRemote = false
m.scene.isLoading = false
2023-02-04 05:26:55 +00:00
end if
end sub
2023-09-10 23:30:40 +00:00
' accepts the raw json string of /system/info/public and returns
' a boolean indicating if ProductName is "Jellyfin Server"
2023-09-13 14:51:49 +00:00
function isJellyfinServer(systemInfo as object) as boolean
2023-10-31 17:41:13 +00:00
data = ParseJson(systemInfo)
if isValid(data) and isValid(data.ProductName)
return LCase(data.ProductName) = m.global.constants.jellyfin_server
2023-09-10 18:48:55 +00:00
end if
2023-10-31 17:41:13 +00:00
return false
2023-09-10 18:48:55 +00:00
end function
' Check if a specific value is inside of an array
function arrayHasValue(arr as object, value as dynamic) as boolean
for each entry in arr
if entry = value
return true
end if
end for
return false
end function
2023-09-17 21:50:24 +00:00
' Takes an array of data, shuffles the order, then returns the array
' uses the Fisher-Yates shuffling algorithm
function shuffleArray(array as object) as object
for i = array.count() - 1 to 1 step -1
j = Rnd(i + 1) - 1
t = array[i] : array[i] = array[j] : array[j] = t
end for
return array
end function
' Create and return a Jellyfin logo poster node
function createLogoPoster()
logoPoster = createObject("roSGNode", "Poster")
logoPoster.id = "overlayLogo"
logoPoster.uri = "pkg:/images/logo.png"
logoPoster.translation = "[70, 53]"
2024-01-09 04:12:58 +00:00
logoPoster.width = "191"
logoPoster.height = "66"
return logoPoster
end function
' Create and return a rectangle node used as a seperator in the overhang
function createSeperator(id as string)
if not isValidAndNotEmpty(id) then return invalid
seperator = createObject("roSGNode", "Rectangle")
seperator.id = id
seperator.color = "#666666"
seperator.width = "2"
seperator.height = "64"
2023-12-06 14:37:20 +00:00
seperator.visible = true
return seperator
end function
'
function createOverhangUser()
userLabel = createObject("roSGNode", "Label")
userLabel.id = "overlayCurrentUser"
userLabel.font = "font:MediumSystemFont"
userLabel.horizAlign = "right"
userLabel.vertAlign = "center"
userLabel.width = "300"
userLabel.height = "64"
return userLabel
end function