' Session management helper functions
' Global content nodes: m.global.server and m.global.user
' Organized via namespaces for clean code organization
import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/baseRequest.bs"
import "pkg:/source/api/userAuth.bs"
import "pkg:/source/constants/homeSlots.bs"
import "pkg:/source/constants/timeouts.bs"
import "pkg:/source/data/SessionDataTransformer.bs"
import "pkg:/source/enums/MediaSegmentType.bs"
import "pkg:/source/migrations.bs"
import "pkg:/source/utils/globals.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/nodeUtils.bs"
import "pkg:/source/utils/translate.bs"
import "pkg:/source/utils/translateLocale.bs"
import "pkg:/source/utils/url.bs"
' ============================================
' SERVER NAMESPACE
' ============================================
namespace server
' Update server URL and populate from API
' @param url - The URL to connect to (will be normalized for API calls)
' @param originalUrl - Optional: The original user-entered URL to persist to registry.
' If not provided, the url parameter will be persisted.
' This preserves the user's input for multi-network access
' while allowing runtime discovery to use canonical URLs.
function UpdateURL(url as string, originalUrl = "" as string) as boolean
if url = "" then return false
' Normalize URL for consistent storage and comparison
normalizedUrl = normalizeUrl(url)
if normalizedUrl = ""
' Clear stale server state on invalid URL
server.Delete()
return false
end if
serverNode = m.global.server
serverNode.serverUrl = normalizedUrl
success = server.Populate()
if success
setSetting("server", server.UrlToPersist(url, originalUrl))
else
' Reset on failure
server.Delete()
end if
return success
end function
' The server URL to persist to the registry after a successful connection:
' the ORIGINAL URL (what the user entered), never the canonical one it was
' normalized or redirected to. Re-discovery then runs on every connection, so
' a canonical URL that only resolves on one network is never saved.
' @param url - The URL UpdateURL was asked to connect to
' @param originalUrl - The user-entered URL, when the caller has one
function UrlToPersist(url as string, originalUrl = "" as string) as string
if originalUrl = "" then return url
return originalUrl
end function
' Fetch and populate server info from Jellyfin API
function Populate() as boolean
serverNode = m.global.server
' Validate URL
if not isValidAndNotEmpty(serverNode.serverUrl)
return false
end if
' Call Jellyfin API
myServerInfo = ServerInfo()
' Validate response
if not isValid(myServerInfo) or myServerInfo.Error = true
return false
end if
if not isValid(myServerInfo.Id)
return false
end if
' Update all server fields directly
serverNode.id = myServerInfo.Id ?? ""
serverNode.name = myServerInfo.ServerName ?? ""
serverNode.localURL = myServerInfo.LocalAddress ?? ""
serverNode.isStartupWizardCompleted = myServerInfo.StartupWizardCompleted ?? false
serverNode.version = myServerInfo.Version ?? ""
serverNode.apiVersion = resolveApiVersion(serverNode.version)
' Check HTTPS
serverNode.isHTTPS = (serverNode.serverUrl.left(8) = "https://")
serverNode.isLocalHTTPS = false
if isValidAndNotEmpty(myServerInfo.LocalAddress)
serverNode.isLocalHTTPS = (myServerInfo.LocalAddress.left(8) = "https://")
end if
' Set loading state
serverNode.isLoaded = true
serverNode.isConnected = true
serverNode.lastConnected = CreateObject("roDateTime").AsSeconds()
' Store raw info for debugging
#if debug
serverNode.rawServerInfo = myServerInfo
#end if
return true
end function
' Reset server to defaults
sub Delete()
' Get local reference to minimize rendezvous
localServer = m.global.server
' Reset to XML defaults (no fields preserved)
nodeUtils.resetNodeToDefaults(localServer, "JellyfinServer", [])
end sub
' Discovers server information and handles redirects.
'
' Convention: all API calls should go through the ApiClient class via GetApi() (for example GetApi().GetUser()).
' Those functions bundle APIRequest() with a synchronous execution strategy (getJson, postVoid, etc.).
'
' Discover() is an intentional exception: it must intercept the roUrlEvent to read GetResponseHeaders()
' so it can detect and persist server-side redirects. The api.* wrappers use GetToString() which follows
' redirects silently, making the redirect URL undetectable. By calling APIRequest() directly and executing
' async, we get the same auth header and HTTPS certificate setup without losing the redirect headers.
'
' @param redirectCount - Internal counter for tracking redirects (default: 0)
' @returns {object} Server info with Error field set on failure
function Discover(redirectCount = 0 as integer) as object
serverNode = m.global.server
' Validate URL
if not isValidAndNotEmpty(serverNode.serverUrl)
return { "Error": true, "ErrorMessage": "Server URL not configured" }
end if
' RFC 7231 recommends max 10 redirects
MAX_REDIRECTS = 10
if redirectCount >= MAX_REDIRECTS
return { "Error": true, "ErrorMessage": "Server redirect loop detected - too many redirects" }
end if
' APIRequest applies auth header and HTTPS certificate authority (same as all other API calls).
' We set the port and execute async ourselves so we can read redirect headers from the roUrlEvent.
req = APIRequest("/system/info/public")
if not isValid(req)
return { "Error": true, "ErrorMessage": "Server URL not configured" }
end if
req.setMessagePort(CreateObject("roMessagePort"))
req.AsyncGetToString()
' Wait for response (timeouts.CONNECTION_CHECK_MS)
resp = wait(timeouts.CONNECTION_CHECK_MS, req.GetMessagePort())
' Handle non-roUrlEvent responses (timeouts, system errors, etc.)
if type(resp) <> "roUrlEvent"
' Check for timeout (wait() returns invalid after timeout expires)
if resp = invalid
return { "Error": true, "ErrorMessage": "Connection timed out - server did not respond within " + stri(timeouts.CONNECTION_CHECK_MS \ 1000).trim() + " seconds" }
end if
' Handle unexpected message types
return { "Error": true, "ErrorMessage": "Unexpected response type: " + type(resp) }
end if
' Check for redirect headers
headers = resp.GetResponseHeaders()
if isValid(headers) and isValid(headers.location)
' Resolve relative URLs and normalize
currentUrl = serverNode.serverUrl
' Resolve redirect against the full request URL (not only the server base)
' This ensures relative Location values resolve correctly with base paths
endpointPath = "/system/info/public"
if Right(currentUrl, 1) = "/"
fullRequestUrl = currentUrl + Mid(endpointPath, 2)
else
fullRequestUrl = currentUrl + endpointPath
end if
resolvedUrl = resolveUrl(fullRequestUrl, headers.location)
' Strip the endpoint path to get the new server base URL
' This keeps any base path (for example /jellyfin) rather than reducing to scheme + authority
newBaseUrl = getServerBaseUrl(resolvedUrl, endpointPath)
normalizedNewUrl = normalizeUrl(newBaseUrl)
' Only follow redirect if path matches /system/info/public (ignores query strings and fragments)
resolvedParts = parseUrlComponents(resolvedUrl)
if isValid(resolvedParts)
' Normalize path: lowercase and remove trailing slash for comparison
resolvedPath = LCase(resolvedParts.path)
if Right(resolvedPath, 1) = "/" and Len(resolvedPath) > 1
resolvedPath = Left(resolvedPath, Len(resolvedPath) - 1)
end if
' Check if path ends with the expected endpoint (supports reverse proxy base paths)
endpointPathLower = "/system/info/public"
pathEndsWithEndpoint = Right(resolvedPath, Len(endpointPathLower)) = endpointPathLower
if pathEndsWithEndpoint
' Validate resolved URL is not empty
if normalizedNewUrl = ""
return { "Error": true, "ErrorMessage": "Server returned invalid redirect URL" }
end if
' Loop detection: if new URL is same as current, we've looped
if urlsEqual(newBaseUrl, currentUrl)
return { "Error": true, "ErrorMessage": "Server redirect loop detected - redirecting to same URL" }
end if
' Update runtime state with canonical URL and retry
' This does not persist the canonical URL to the registry: the caller handles persistence
' This ensures user's original URL is preserved for multi-network access
serverNode.serverUrl = normalizedNewUrl
result = server.Discover(redirectCount + 1)
' Add redirect info to error if retry failed
if result.Error
result.UpdatedUrl = normalizedNewUrl
result.ErrorMessage = result.ErrorMessage + " (Note: Server redirected us to " + normalizedNewUrl + ")"
end if
return result
end if
end if
end if
' Handle non-200 responses
if resp.GetResponseCode() <> 200
return { "Error": true, "ErrorCode": resp.GetResponseCode(), "ErrorMessage": resp.GetFailureReason() }
end if
' Parse and return response
responseString = resp.GetString()
if isValid(responseString) and responseString <> ""
result = ParseJson(responseString)
if isValid(result)
result.Error = false
return result
end if
end if
return { "Error": true, "ErrorMessage": "Does not appear to be a Jellyfin Server" }
end function
end namespace
' ============================================
' USER NAMESPACE
' ============================================
namespace user
' Process user login from API authentication response
sub Login(userData as object, saveCredentials = false as boolean)
if not isValid(userData) then return
' Check for valid user ID in either location (direct or nested)
' Note: Jellyfin API returns "Id" (capital I) in JSON, but we normalize to lowercase "id" on ContentNode
'
' Every branch tests that the id is present and non-empty. The nested one used
' to test only `isValid`, which is `input <> invalid`, so an `Id` of "" passed
' the guard and this sub ran to completion on a payload it should have refused.
' That is worse now than it was: `isLoaded` below is the app's authoritative
' "somebody is signed in" signal, so an empty id did not only sign in emptily,
' it made user.IsAuthenticated() answer true about it.
hasValidId = false
if isValid(userData.id) and userData.id <> ""
hasValidId = true
else if isValid(userData.Id) and userData.Id <> ""
hasValidId = true
else if isValid(userData.json) and isValid(userData.json.User) and isValidAndNotEmpty(userData.json.User.Id)
hasValidId = true
end if
if not hasValidId then return
' Get local reference to minimize rendezvous
userNode = m.global.user
' Create fresh settings node for this user
freshSettings = CreateObject("roSGNode", "JellyfinUserSettings")
userNode.settings = freshSettings
' Load defaults from settings.json
user.settings.SaveDefaults()
' Process user data
if not isValid(userData.json)
' Data from AboutMe() - already in correct format
myAuthToken = userNode.authToken
myPrimaryImageTag = userData.PrimaryImageTag
' Copy all fields EXCEPT Configuration and Policy (these are transformed separately)
for each key in userData
if key <> "Configuration" and key <> "Policy"
' Normalize Id to lowercase id for ContentNode field
if LCase(key) = "id"
userNode.id = userData[key]
else
userNode[key] = userData[key]
end if
end if
end for
' Transform Configuration and Policy to nested content nodes
transformer = new SessionDataTransformer()
if isValid(userData.Configuration)
userNode.config = transformer.transformUserConfiguration(userData.Configuration)
end if
if isValid(userData.Policy)
userNode.policy = transformer.transformUserPolicy(userData.Policy)
end if
if saveCredentials
if isValidAndNotEmpty(myPrimaryImageTag)
registryWrite("primaryImageTag", myPrimaryImageTag, userNode.id)
end if
if isValidAndNotEmpty(myAuthToken)
registryWrite("authToken", myAuthToken, userNode.id)
end if
end if
else
' Data from UserData object (getToken response)
myPrimaryImageTag = userData.json.User.PrimaryImageTag
myAuthToken = userData.json.AccessToken
' Copy all fields EXCEPT Configuration and Policy (these are transformed separately)
for each key in userData.json.User
if key <> "Configuration" and key <> "Policy"
' Normalize Id to lowercase id for ContentNode field
if LCase(key) = "id"
userNode.id = userData.json.User[key]
else
userNode[key] = userData.json.User[key]
end if
end if
end for
' Transform Configuration and Policy to nested content nodes
transformer = new SessionDataTransformer()
if isValid(userData.json.User.Configuration)
userNode.config = transformer.transformUserConfiguration(userData.json.User.Configuration)
end if
if isValid(userData.json.User.Policy)
userNode.policy = transformer.transformUserPolicy(userData.json.User.Policy)
end if
if saveCredentials
if isValidAndNotEmpty(myPrimaryImageTag)
registryWrite("primaryImageTag", myPrimaryImageTag, userNode.id)
end if
if isValidAndNotEmpty(myAuthToken)
registryWrite("authToken", myAuthToken, userNode.id)
end if
end if
end if
if isValidAndNotEmpty(myPrimaryImageTag)
userNode.primaryImageTag = myPrimaryImageTag
end if
if isValidAndNotEmpty(myAuthToken)
userNode.authToken = myAuthToken
end if
' Create friendly name (URL-safe, no spaces or special chars)
' Used for serverDeviceName which appears in URLs and HTTP headers
userNode.friendlyName = user.SanitizeUsernameForUrl(userNode.name)
' Migrations
if isValid(userData.LastRunVersion)
userNode.LastRunVersion = userData.LastRunVersion
end if
' Process registry settings
' Load ALL user-specific settings from user's registry section
userSettings = RegistryReadAll(userNode.id)
' Get field types once to check for field existence (more reliable than hasField)
userNodeFieldTypes = userNode.getFieldTypes()
settingsNodeFieldTypes = userNode.settings.getFieldTypes()
' Use node structure as source of truth to determine where each setting belongs
for each setting in userSettings
print "Loading user setting:", setting, "=", userSettings[setting]
' Skip global settings (loaded from global section separately)
' Skip display settings (handled separately below via TransformDisplaySettings)
' Skip homeSection fields: these are server-authoritative (fetched fresh from
' DisplayPreferences in LoadUserPreferences) and must not be loaded from the
' registry. Stale registry values would override the server's current arrangement.
if not isGlobalSetting(setting) and not setting.StartsWith("display.") and not setting.StartsWith("homeSection")
' Skip auth-flow-authoritative credentials. Every caller sets these on
' userNode before invoking Login() (or they are extracted from the
' response payload above); reapplying them from the registry here would
' clobber a freshly-issued token with a stale one left over from a
' previous session, for example the Quick Connect re-auth case where a new
' AccessToken is issued but the user's registry still holds the old one.
if setting = "authToken" or setting = "primaryImageTag"
print "Skipping auth-flow field from registry:", setting
continue for
end if
' Check where this setting belongs based on field existence
if userNodeFieldTypes.DoesExist(setting)
' Skip node-type fields - these are server-authoritative and never loaded from registry
' This prevents registry tampering of nested content nodes (policy, config, settings)
if userNode.getFieldType(setting) = "node"
print "Skipping node-type field from registry:", setting
continue for
end if
print "Setting found on user node:", setting
' Field exists on user node - set it there
userNode.setField(setting, userSettings[setting])
else if settingsNodeFieldTypes.DoesExist(setting)
print "Setting found on user settings node:", setting
' Field exists on settings node - use settings system with type conversion
user.settings.Save(setting, userSettings[setting])
else
print "user.Login: Unknown setting:", setting
' Unknown field - registry-only key (like "serverId"), skip loading to node
' It will remain in registry for getSavedUsers() etc.
end if
end if
end for
' Load global settings from global registry section (JellyRock or test-global)
' These settings apply to all users on this device
user.settings.LoadGlobals()
' Transform display settings from flat format to nested structure
' Registry stores: "display.libraryId.key" = "value"
' We need: displaySettings[libraryId][key] = value
displaySettings = user.settings.TransformDisplaySettings(userSettings)
' Copy displaySettings to user.settings node
if isValid(displaySettings)
userNode.settings.displaySettings = displaySettings
end if
' Enable auto-sync AFTER loading all initial settings
' This prevents observers from firing during data load
userNode.settings.callFunc("enableAutoSync")
' Save credentials if requested
setUserSetting("serverId", m.global.server.id)
if saveCredentials
setUserSetting("authToken", userNode.authToken)
setUserSetting("username", userNode.name)
if isValid(userNode.primaryImageTag)
setUserSetting("primaryImageTag", userNode.primaryImageTag)
end if
end if
if userNode.settings.globalRememberMe
setSetting("active_user", userNode.id)
end if
' Post-login setup
user.SetServerDeviceName()
user.LoadUserPreferences()
' Apply user's theme color settings to Constants and refresh scene
applyThemeColorOverrides(userNode.settings)
m.global.sceneManager.callFunc("refreshThemeColors")
' The authoritative "a session is established" signal, set at the end of this
' sub (only `loadedAt` follows), so it is set once every guard above has been cleared
' and the node is fully populated. Every `return` before this point leaves it
' false, which is the whole point: this sub has no return value, so this
' field IS how it reports that it took. See user.IsAuthenticated().
userNode.isLoaded = true
userNode.loadedAt = CreateObject("roDateTime").AsSeconds()
end sub
' Is a user actually signed in RIGHT NOW?
'
' Reads `isLoaded`, not `id` / `authToken`, and that distinction is the whole
' reason this exists. JellyfinUser.xml states the rule as "if user.id is not
' empty, user IS authenticated", and that is false in practice, because two
' paths populate the node SPECULATIVELY before anything has been authenticated:
'
' onUserSelected writes `id` from the picker, before any auth
' validateSavedToken writes `authToken`, before validating it
'
' Neither is rolled back when the attempt fails (the failure path clears the
' REGISTRY, not the node), so after a rejected token `m.global.user` still
' carries both fields and answers "authenticated" to anyone who asks it that
' way. Those writes are not sloppiness. validateSavedToken's is required,
' because AboutMe() reads the token straight back off the node through
' buildAuthHeader(). So the fix is a signal that only a completed login sets,
' rather than an attempt to stop the speculative writes.
'
' `isLoaded` is that signal. It was already declared for exactly this ("tracks
' whether full user data has been fetched from server") and was dead: written
' only by SessionDataTransformer.transformUser, which builds a throwaway node
' the login flow never touches, and read by nobody. Logout() clears it for free
' via resetNodeToDefaults.
function IsAuthenticated() as boolean
globalUser = m.global.user
if not isValid(globalUser) then return false
return globalUser.isLoaded = true
end function
' Logout and reset to defaults
sub Logout()
' Get local reference to minimize rendezvous
localUser = m.global.user
' Reset ALL user fields including settings (no fields preserved)
nodeUtils.resetNodeToDefaults(localUser, "JellyfinUser", [])
' Create fresh nested nodes with XML defaults
freshSettings = CreateObject("roSGNode", "JellyfinUserSettings")
freshConfig = CreateObject("roSGNode", "JellyfinUserConfiguration")
freshPolicy = CreateObject("roSGNode", "JellyfinUserPolicy")
localUser.settings = freshSettings
localUser.config = freshConfig
localUser.policy = freshPolicy
' Reset theme colors to defaults from settings.json and refresh scene
resetThemeColors()
if isValid(m.global.sceneManager)
m.global.sceneManager.callFunc("refreshThemeColors")
m.global.sceneManager.currentUser = ""
end if
end sub
' Set device name from user name
sub SetServerDeviceName()
localGlobal = m.global
friendlyName = ""
if isValid(localGlobal.user) and isValidAndNotEmpty(localGlobal.user.friendlyName)
friendlyName = localGlobal.user.friendlyName
end if
deviceName = user.ComputeServerDeviceName(localGlobal.device.id, friendlyName, localGlobal.server.version)
' update global if needed
if localGlobal.device.serverDeviceName <> deviceName
tmpDevice = localGlobal.device
tmpDevice.AddReplace("serverDeviceName", deviceName)
m.global.setFields({ device: tmpDevice })
end if
end sub
' Compute the Jellyfin DeviceId this device advertises. Every session-creating channel
' (auth header at baseRequest.bs, the ws:// socket URL via remoteProtocol.buildSocketUrl,
' and the /pair report) reads it from device.serverDeviceName, so it must be stable across
' the app lifecycle or the device splits into two Jellyfin sessions.
'
' Appending the sanitized username was a workaround for OLD Jellyfin servers that revoked a
' device's sibling auth tokens on re-authentication, letting multiple users' saved tokens
' coexist on one Roku for fast user-switching. But the suffix is set post-login, making the
' DeviceId unstable across startup (bare before login, suffixed after), which on the HTTP
' (ws://) path splits one device+user into two sessions and breaks the #668 cold-launch cast
' producer (the phantom keys on one DeviceId, the live socket lands on the other).
'
' Servers >= 10.11 tolerate multiple auth tokens per DeviceId (proven on 10.11.11), so the
' suffix is unneeded there: keep the DeviceId bare + stable. Older servers keep the suffix
' (unchanged behavior; those servers have no cold-cast producer anyway, since it is net9/10.11
' floored). An empty/unknown server version falls through to the suffixed path, matching the
' conservative pre-10.11 behavior. See decision deviceid-header-authoritative (which supersedes
' deviceid-suffix-gate-10.11) + ADR 0023.
'
' @param deviceId - the stable per-device id (roDeviceInfo GetChannelClientId)
' @param friendlyName - URL-safe username, or "" when no user is resolved
' @param serverVersion - connected Jellyfin server version string (for example "10.11.11")
' @returns the DeviceId to advertise on the auth header, ws:// socket, and /pair report
function ComputeServerDeviceName(deviceId as string, friendlyName as string, serverVersion as string) as string
if versionChecker(serverVersion, "10.11.0") then return deviceId
if isValidAndNotEmpty(friendlyName) then return deviceId + friendlyName
return deviceId
end function
' Sanitize username to be URL-safe (removes spaces and special characters)
' Used for friendlyName which appears in serverDeviceName URLs and HTTP headers
' @param name - The raw username to sanitize
' @returns URL-safe username with only alphanumeric, hyphens, and underscores
function SanitizeUsernameForUrl(name as string) as string
if not isValidAndNotEmpty(name) then return ""
' Remove all characters except alphanumeric, hyphens, and underscores
' This prevents "Malformed input to URL function" errors from spaces and special chars
regex = CreateObject("roRegex", "[^a-zA-Z0-9\-\_]", "")
return regex.ReplaceAll(name, "")
end function
' Load user preferences from Jellyfin server
sub LoadUserPreferences()
id = m.global.user.id
jsonResponse = GetApi().GetDisplayPreferences("usersettings", { userId: id, client: "emby" })
if isValid(jsonResponse)
userNode = m.global.user
' Save ShowBackdrop to config node (always save server value regardless of JellyRock override)
if isValid(jsonResponse.ShowBackdrop)
userNode.config.showBackdrop = toBoolean(jsonResponse.ShowBackdrop)
else
' Default to true if server doesn't provide value
userNode.config.showBackdrop = true
end if
if isValid(jsonResponse.CustomPrefs)
' Save UseEpisodeImagesInNextUpAndResume to config node
tmpSetting = jsonResponse.CustomPrefs.useEpisodeImagesInNextUpAndResume
if isValid(tmpSetting)
' Set field on nested config node
userNode.config.useEpisodeImagesInNextUpAndResume = toBoolean(tmpSetting)
end if
' Home sections: server-authoritative when uiHomeUseWebArrangement is true (default).
' When false, skip server values and use hardcoded defaults.
if userNode.settings.uiHomeUseWebArrangement
if user.hasHomeSectionData(jsonResponse.CustomPrefs)
print "LoadUserPreferences: Using server CustomPrefs for home sections"
user.SaveUserHomeSections(jsonResponse.CustomPrefs)
else
print "LoadUserPreferences: CustomPrefs has no homesection data, using defaults"
user.SaveUserHomeSections(user.getDefaultHomeSections())
end if
else
print "LoadUserPreferences: uiHomeUseWebArrangement is false, using defaults"
user.SaveUserHomeSections(user.getDefaultHomeSections())
end if
if isValid(jsonResponse.CustomPrefs["landing-livetv"])
setUserSetting("displayLiveTvLanding", jsonResponse.CustomPrefs["landing-livetv"])
else
unsetUserSetting("displayLiveTvLanding")
end if
' Extract media segment action preferences from CustomPrefs
' Web client stores these with key format: segmentTypeAction__[Type]
segmentTypes = [MediaSegmentType.INTRO, MediaSegmentType.OUTRO, MediaSegmentType.COMMERCIAL, MediaSegmentType.PREVIEW, MediaSegmentType.RECAP, MediaSegmentType.UNKNOWN]
for each segmentType in segmentTypes
customPrefsKey = "segmentTypeAction__" + segmentType
configFieldKey = "segmentAction" + segmentType
if isValid(jsonResponse.CustomPrefs[customPrefsKey])
userNode.config[configFieldKey] = jsonResponse.CustomPrefs[customPrefsKey]
end if
end for
else
print "LoadUserPreferences: No valid CustomPrefs from server, using defaults"
user.SaveUserHomeSections(user.getDefaultHomeSections())
end if
end if
' Resolve the post-login locale whether or not the request worked, so the
' user's own Display Language still applies when the server is unreachable.
resolvedLocale = resolvePostLoginLocale(jsonResponse)
if resolvedLocale <> m.global.translationLocale
loadTranslations(resolvedLocale)
end if
end sub
' Get default home sections configuration
function getDefaultHomeSections() as object
return {
homesection0: "smalllibrarytiles",
homesection1: "resume",
homesection2: "nextup",
homesection3: "activerecordings",
homesection4: "livetv",
homesection5: "latestmedia"
}
end function
' Check if CustomPrefs contains any homesection data
function hasHomeSectionData(prefs as object) as boolean
if not isValid(prefs) then return false
for i = 0 to homeSlots.SLOT_COUNT - 1
key = "homesection" + i.toStr()
if prefs.DoesExist(key)
return true
end if
end for
return false
end function
' Apply server home sections to the settings node (in-memory only, no registry).
' Home sections are server-authoritative: the Jellyfin server's DisplayPreferences
' is the single source of truth. Writing to registry would create a stale cache that
' masks server-side changes (for example, user rearranges sections in the web client).
sub SaveUserHomeSections(userPreferences as object)
rowTypes = []
if not isValid(userPreferences)
userPreferences = user.getDefaultHomeSections()
end if
for i = 0 to homeSlots.SLOT_COUNT - 1
oldHomeSectionKey = "homesection" + i.toStr()
newHomeSectionKey = "homeSection" + i.toStr()
if not userPreferences.DoesExist(oldHomeSectionKey)
userPreferences.AddReplace(oldHomeSectionKey, "none")
end if
rowType = LCase(userPreferences[oldHomeSectionKey])
if not isValid(rowType) then rowType = "none"
if inArray(rowTypes, rowType)
user.settings.Save(newHomeSectionKey, "none")
else
user.settings.Save(newHomeSectionKey, rowType)
if rowType <> "none"
rowTypes.push(rowType)
end if
end if
end for
end sub
end namespace
' ============================================
' USER SETTINGS NAMESPACE
' ============================================
namespace user.settings
' Save setting to content node with type conversion
sub Save(name as string, value as dynamic)
if not isValid(name) or not isValid(value) then return
settingsNode = m.global.user.settings
' All fields should be defined in JellyfinUserSettings.xml
' If field doesn't exist, it's a programming error
if not settingsNode.hasField(name)
print `ERROR: Attempted to save unknown setting: ${name}`
return
end if
' Update field with type conversion based on XML-defined field type
fieldType = settingsNode.getFieldType(name)
valueType = type(value)
if fieldType = "boolean"
if valueType = "roBoolean" or valueType = "Boolean"
settingsNode.setField(name, value)
else if valueType = "roString" or valueType = "String"
settingsNode.setField(name, toBoolean(value))
else if valueType = "roInt" or valueType = "roInteger" or valueType = "Integer"
' Convert integer to boolean (0 = false, non-zero = true)
settingsNode.setField(name, value <> 0)
else
' Fallback - convert to string then to boolean
settingsNode.setField(name, toBoolean(value.toStr()))
end if
else if fieldType = "integer"
if valueType = "roInt" or valueType = "roInteger" or valueType = "Integer"
settingsNode.setField(name, value)
else if valueType = "roString" or valueType = "String"
settingsNode.setField(name, value.toInt())
else if valueType = "roBoolean" or valueType = "Boolean"
' Convert boolean to integer (true = 1, false = 0)
if value then settingsNode.setField(name, 1) else settingsNode.setField(name, 0)
else if valueType = "roFloat" or valueType = "Float" or valueType = "roDouble" or valueType = "Double"
settingsNode.setField(name, Int(value))
else
' Fallback - convert to string then to int
settingsNode.setField(name, value.toStr().toInt())
end if
else if fieldType = "float"
if valueType = "roFloat" or valueType = "Float" or valueType = "roDouble" or valueType = "Double"
settingsNode.setField(name, value)
else if valueType = "roString" or valueType = "String"
settingsNode.setField(name, value.toFloat())
else if valueType = "roInt" or valueType = "roInteger" or valueType = "Integer"
settingsNode.setField(name, Cdbl(value))
else if valueType = "roBoolean" or valueType = "Boolean"
' Convert boolean to float (true = 1.0, false = 0.0)
if value then settingsNode.setField(name, 1.0) else settingsNode.setField(name, 0.0)
else
' Fallback - convert to string then to float
settingsNode.setField(name, value.toStr().toFloat())
end if
else if fieldType = "assocarray"
' AssociativeArray field (for example displaySettings)
settingsNode.setField(name, value)
else
' String or other field type - convert to string if needed
if valueType = "roString" or valueType = "String"
settingsNode.setField(name, value)
else if valueType = "roBoolean" or valueType = "Boolean"
if value then settingsNode.setField(name, "true") else settingsNode.setField(name, "false")
else if valueType = "roInt" or valueType = "roInteger" or valueType = "Integer"
settingsNode.setField(name, value.toStr())
else if valueType = "roFloat" or valueType = "Float" or valueType = "roDouble" or valueType = "Double"
settingsNode.setField(name, value.toStr())
else
' Fallback - try toStr() method
settingsNode.setField(name, value.toStr())
end if
end if
end sub
' Load all default settings from settings.json
sub SaveDefaults()
configTree = GetConfigTree()
if not isValid(configTree) then return
loadDefaultsRecursive(configTree)
user.settings.LoadGlobals()
user.SetServerDeviceName()
end sub
' Load global settings from registry
sub LoadGlobals()
' Load global settings from the global registry section
' Returns "JellyRock" in production, "test-global" during tests
' These settings apply to all users on this device
globalSection = getGlobalRegistrySection()
globalRegistry = RegistryReadAll(globalSection)
for each key in globalRegistry
' Only load keys that start with "global" (actual global settings)
if isGlobalSetting(key)
value = getSetting(key)
if isValid(value)
user.settings.Save(key, value)
end if
end if
end for
end sub
' Recursively traverse config tree (unlimited depth)
sub loadDefaultsRecursive(configItems as object)
if not isValid(configItems) then return
for each item in configItems
' Save this item's default if present
if isValid(item.default) and isValid(item.settingName)
user.settings.Save(item.settingName, item.default)
end if
' Recursively process children (handles any nesting depth)
if isValid(item.children) and item.children.Count() > 0
loadDefaultsRecursive(item.children)
end if
end for
end sub
' Transform display settings from registry flat format to nested structure
' Registry stores: "display.libraryId.key" = "value"
' Returns: displaySettings[libraryId][key] = value
function TransformDisplaySettings(registryData as object) as object
displaySettings = {}
if not isValid(registryData) then return displaySettings
for each key in registryData.Keys()
if key.StartsWith("display.")
' Parse "display.libraryId.settingKey" into a nested object
parts = key.Split(".")
if parts.Count() = 3 and isValidAndNotEmpty(parts[1]) and isValidAndNotEmpty(parts[2])
libraryId = parts[1]
settingKey = parts[2]
' Create library object if doesn't exist
if not displaySettings.DoesExist(libraryId)
displaySettings[libraryId] = {}
end if
' Store setting value with proper type conversion
rawValue = registryData[key]
' Convert boolean fields from string to actual boolean
if settingKey = "sortAscending"
displaySettings[libraryId][settingKey] = toBoolean(rawValue)
else
' Other fields stored as-is (strings, etc.)
displaySettings[libraryId][settingKey] = rawValue
end if
end if
end if
end for
return displaySettings
end function
end namespace