source_utils_session.bs

' 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