' needed for setUserSetting() and unsetUserSetting()
import "pkg:/source/utils/session.bs"
' Read config tree from json config file and return
function GetConfigTree()
return ParseJSON(ReadAsciiFile("pkg:/settings/settings.json"))
end function
' Generic registry accessors
function registryRead(key, section = invalid)
if not isValid(section) then return invalid
reg = CreateObject("roRegistrySection", section)
if reg.exists(key) then return reg.read(key)
return invalid
end function
sub registryWrite(key, value, section = invalid)
if not isValid(section) then return
reg = CreateObject("roRegistrySection", section)
reg.write(key, value)
reg.flush()
end sub
' Convert dynamic value to string for registry storage
function valueToString(value as dynamic) as string
if not isValid(value) then return ""
valueType = type(value)
if valueType = "roString" or valueType = "String"
return value
else if valueType = "roBoolean" or valueType = "Boolean"
if value then return "true" else return "false"
else if valueType = "roInt" or valueType = "roInteger" or valueType = "Integer"
return value.toStr()
else if valueType = "roFloat" or valueType = "Float" or valueType = "roDouble" or valueType = "Double"
return value.toStr()
else
' Fallback - try toStr() method
return value.toStr()
end if
end function
sub registryDelete(key, section = invalid)
if not isValid(section) then return
reg = CreateObject("roRegistrySection", section)
reg.delete(key)
reg.flush()
end sub
' Return all data found inside a registry section
function RegistryReadAll(section as string) as dynamic
if section = "" then return invalid
registry = CreateObject("roRegistrySection", section)
regKeyList = registry.GetKeyList()
registryData = {}
for each item in regKeyList
if registry.Exists(item)
registryData.AddReplace(item, registry.Read(item))
end if
end for
return registryData
end function
' Return an array of all the registry section keys
function getRegistrySections() as object
registry = CreateObject("roRegistry")
return registry.GetSectionList()
end function
' Helper to get the global registry section name
' Returns "test-global" during tests, "JellyRock" in production
function getGlobalRegistrySection() as string
' Detect test mode by checking if m.global.user.id starts with "test-"
' This is more reliable than compile-time flags since test user IDs always use this pattern
globalUser = m.global.user
if isValid(globalUser) and isValid(globalUser.id)
userId = globalUser.id
if type(userId) = "roString" or type(userId) = "String"
if userId.StartsWith("test-")
return "test-global"
end if
end if
end if
return "JellyRock"
end function
' "JellyRock" registry accessors for the default global settings
function getSetting(key, defaultValue = invalid)
value = registryRead(key, getGlobalRegistrySection())
if not isValid(value) then return defaultValue
return value
end function
sub setSetting(key, value)
' Registry only accepts strings - convert value to string
registryWrite(key, valueToString(value), getGlobalRegistrySection())
end sub
sub unsetSetting(key)
registryDelete(key, getGlobalRegistrySection())
end sub
' User registry accessors for the currently active user
function getUserSetting(key as string) as dynamic
globalUser = m.global.user
if key = "" or not isValid(globalUser.id) then return invalid
value = registryRead(key, globalUser.id)
return value
end function
sub setUserSetting(key as string, value as dynamic)
' Get local reference to minimize rendezvous
localUser = m.global.user
if not isValid(localUser.id) then return
' Use node structure as source of truth - check where this field belongs
if localUser.hasField(key)
' Field exists on user node - set it there
localUser.setField(key, value)
else if localUser.settings.hasField(key)
' Field exists on settings node - use settings system
user.settings.Save(key, value)
else
' Unknown field - registry-only (backward compatibility, or keys like "serverId")
' Don't set on any node, just persist to registry
end if
' Always persist to registry for all keys
registryWrite(key, valueToString(value), localUser.id)
end sub
sub unsetUserSetting(key as string)
globalUser = m.global.user
if not isValid(globalUser.id) then return
' Delete from registry only (for sensitive data like token, username)
' These fields are excluded from the observer, so deleting from registry
' won't trigger the observer to re-save them
registryDelete(key, globalUser.id)
end sub
' Helper to determine if a setting is global (applies to all users)
function isGlobalSetting(key as string) as boolean
return key.StartsWith("global")
end function
' Filter registry keys to find those that should be deleted during a settings reset
' Preserves session/identity keys, deletes everything else
' @param {object} allKeys - associative array of all registry key-value pairs
' @param {object} preserveKeys - array of key names to preserve
' @return {object} - array of key names that should be deleted
function getSettingKeysToDelete(allKeys as object, preserveKeys as object) as object
keysToDelete = []
for each key in allKeys
shouldPreserve = false
for each preserveKey in preserveKeys
' Case-insensitive compare: roAssociativeArray iteration returns lowercase keys
if LCase(key) = LCase(preserveKey)
shouldPreserve = true
exit for
end if
end for
if not shouldPreserve
keysToDelete.push(key)
end if
end for
return keysToDelete
end function
' Recursivly search the config tree for entry with settingname equal to key
function findConfigTreeKey(key as string, tree)
for each item in tree
if isValid(item.settingName) and item.settingName = key then return item
if isValid(item.children) and item.children.Count() > 0
result = findConfigTreeKey(key, item.children)
if isValid(result) then return result
end if
end for
return invalid
end function
' Returns an array of saved users from the registry
' that belong to the active server
function getSavedUsers() as object
registrySections = getRegistrySections()
savedUsers = []
for each section in registrySections
if LCase(section) <> "jellyrock"
savedUsers.push(section)
end if
end for
savedServerUsers = []
for each userId in savedUsers
userArray = {
id: userId
}
token = registryRead("authToken", userId)
username = registryRead("username", userId)
if isValid(username)
userArray["username"] = username
end if
serverId = registryRead("serverId", userId)
if isValid(serverId)
userArray["serverId"] = serverId
end if
primaryImageTag = registryRead("primaryImageTag", userId)
if isValid(primaryImageTag)
print "Found Saved Primary Image Tag: ", primaryImageTag, " for user: ", userId
userArray["primaryImageTag"] = primaryImageTag
end if
if isValid(username) and isValid(token) and serverIdsMatch(serverId, m.global.server.id)
savedServerUsers.push(userArray)
end if
end for
return savedServerUsers
end function
' Saved-server list persistence. Pure registry operations on the saved_servers key —
' config.bs's domain, alongside getSavedUsers — called from both SetServerScreen (render
' thread, delete) and the loginRouter coordinator (main thread, save).
sub SaveServerList()
' Save this server to the list of previously-used servers shown in the server picker.
' baseUrl: canonical URL (lowercase) — used for deduplication against SSDP-discovered servers.
' originalUrl: user-entered URL from registry — shown in the picker and pre-filled on re-selection,
' so inferServerUrl() can re-discover the correct protocol on each connection.
' id: Jellyfin server ID — primary deduplication key, robust to URL changes (e.g. HTTP→HTTPS).
globalServer = m.global.server
serverUrl = globalServer.serverUrl
serverId = globalServer.id
serverName = globalServer.name
originalUrl = getSetting("server") ' set correctly by server.UpdateURL() before this is called
if isValid(serverUrl)
serverUrl = LCase(serverUrl) ' canonical URL always lowercase for comparison
end if
if not isValidAndNotEmpty(originalUrl)
originalUrl = serverUrl ' fallback: use canonical if original is somehow missing
end if
savedServers = { serverList: [] }
saved = getSetting("saved_servers")
if isValid(saved)
parsed = ParseJson(saved)
if isValid(parsed) and isValid(parsed.serverList)
savedServers = parsed
end if
end if
' Check for an existing entry (ID-based first; URL fallback for old entries without id)
for i = 0 to savedServers.serverList.Count() - 1
item = savedServers.serverList[i]
isMatch = false
if isValidAndNotEmpty(serverId) and isValidAndNotEmpty(item.id)
isMatch = serverIdsMatch(item.id, serverId)
else if LCase(item.baseUrl) = serverUrl
isMatch = true
end if
if isMatch
' Update in-place: refresh mutable server identity fields (name, id, baseUrl, originalUrl).
' iconUrl/iconWidth/iconHeight are static app branding defaults and are not changed.
savedServers.serverList[i].name = serverName
savedServers.serverList[i].id = serverId
savedServers.serverList[i].baseUrl = serverUrl ' keep canonical URL current (e.g. HTTP→HTTPS)
savedServers.serverList[i].originalUrl = originalUrl
setSetting("saved_servers", FormatJson(savedServers))
return
end if
end for
' No existing entry found — append a new one
savedServers.serverList.Push({
name: serverName,
id: serverId,
baseUrl: serverUrl,
originalUrl: originalUrl,
iconUrl: "pkg:/images/branding/logo-icon120.jpg",
iconWidth: 120,
iconHeight: 120
})
setSetting("saved_servers", FormatJson(savedServers))
end sub
sub DeleteFromServerList(idOrUrl as string)
' idOrUrl should be the server's id when available (passed from itemToDelete.id).
' Falls back to a canonical baseUrl for legacy entries that predate the id field.
' ID match is tried first so deletion is correct even when the saved entry's baseUrl
' differs from the picker item's baseUrl (e.g. a saved HTTPS entry matched via SSDP
' on HTTP — the picker item carries the SSDP baseUrl, not the saved one).
saved = getSetting("saved_servers")
if not isValid(saved) then return
savedServers = ParseJson(saved)
newServers = { serverList: [] }
normalizedInput = LCase(idOrUrl) ' for URL fallback comparison (baseUrls are always lowercase)
for each item in savedServers.serverList
keepEntry = true
if serverIdsMatch(item.id, idOrUrl)
keepEntry = false ' ID match — remove this entry
else if item.baseUrl = normalizedInput
keepEntry = false ' URL fallback — remove this entry
end if
if keepEntry
newServers.serverList.Push(item)
end if
end for
setSetting("saved_servers", FormatJson(newServers))
end sub
' True when two Jellyfin server ids refer to the same server. Jellyfin GUIDs are canonical
' lower-case hex, but fold both sides so a mis-cased id (e.g. from an external deep-link/cast
' sender) still matches. The single source of truth for "same server?" across the saved-server
' list and the saved-user filter — keeps every id comparison consistent.
function serverIdsMatch(a as dynamic, b as dynamic) as boolean
return isValidAndNotEmpty(a) and isValidAndNotEmpty(b) and LCase(a) = LCase(b)
end function
' Pure: match the saved_servers JSON against a server GUID, case-insensitively (via
' serverIdsMatch). Split out from the registry read (findSavedServerByGuid in replayRoute.bs)
' so the match logic is unit-testable without touching the registry (getSetting reads the real
' "JellyRock" section, which tests must not write). Lives here alongside the saved_servers shape.
function findServerInList(savedJson as dynamic, guid as string) as object
if not isValidAndNotEmpty(guid) or not isValid(savedJson) then return invalid
parsed = ParseJson(savedJson)
if not isValid(parsed) or not isValid(parsed.serverList) then return invalid
for each entry in parsed.serverList
if serverIdsMatch(entry.id, guid) then return entry
end for
return invalid
end function