import "pkg:/source/utils/config.bs" function isNodeEvent(msg, field as string) as boolean return type(msg) = "roSGNodeEvent" and msg.getField() = field end function function getMsgPicker(msg, subnode = "" as string) as object 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 end function function getButton(msg, subnode = "buttons" as string) as object buttons = msg.getRoSGNode().findNode(subnode) if buttons = invalid then return invalid active_button = buttons.focusedChild return active_button end function function leftPad(base as string, fill as string, length as integer) as string while len(base) < length base = fill + base end while return base end function function ticksToHuman(ticks as longinteger) as string 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 hours = time.getHours() minHourDigits = 1 if m.global.device.clockFormat = "12h" 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 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 if a < b then return 1 if int(a / b) = a / b return a / b end if return a / b + 1 end function 'Returns the item selected or -1 on backpress or other unhandled closure of dialog. function get_dialog_result(dialog, port) 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 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 end function function show_dialog(message as string, options = [], defaultSelection = 0) as integer 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 function message_dialog(message = "" as string) return show_dialog(message, ["OK"]) end function function option_dialog(options, message = "", defaultSelection = 0) as integer return show_dialog(message, options, defaultSelection) end function ' 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 ' 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 end if 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") end if req.AsyncGetToString() end for handled = 0 timeout = CreateObject("roTimespan") 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 end if 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." end if return "" end function ' 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) 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) end if ' 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 end if finalCandidates = [] if isValid(port) and port <> "" ' if the port is defined just use that for each candidate in protoCandidates finalCandidates.push(candidate + port + path) end for else ' the port wasnt declared so use default jellyfin and proto ports for each candidate in protoCandidates: ' proto default finalCandidates.push(candidate + path) ' jellyfin defaults if candidate.startswith("https") finalCandidates.push(candidate + ":8920" + path) else if candidate.startswith("http") finalCandidates.push(candidate + ":8096" + path) end if end for end if return finalCandidates end function 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 function isValid(input as dynamic) as boolean return input <> invalid end function ' Returns whether or not passed value is valid and not empty ' Accepts a string, or any countable type (arrays and lists) function isValidAndNotEmpty(input as dynamic) as boolean 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" trimmedInput = input.trim() return trimmedInput <> "" else if inputType = "rosgnode" inputId = input.id return inputId <> invalid 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 function parseUrl(url as string) as object rgx = CreateObject("roRegex", "^(.*:)//([A-Za-z0-9\-\.]+)(:[0-9]+)?(.*)$", "") return rgx.Match(url) end function ' Returns true if the string is a loopback, such as 'localhost' or '127.0.0.1' 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 ' 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) end function ' ' 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) 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 end function 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 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. ' 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 end sub sub stopLoadingSpinner() if not isValid(m.scene) m.scene = m.top.getScene() end if if m.scene.isLoading m.scene.disableRemote = false m.scene.isLoading = false end if end sub ' accepts the raw json string of /system/info/public and returns ' a boolean indicating if ProductName is "Jellyfin Server" function isJellyfinServer(systemInfo as object) as boolean data = ParseJson(systemInfo) if isValid(data) and isValid(data.ProductName) return LCase(data.ProductName) = m.global.constants.jellyfin_server end if return false 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 ' 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