source_api_ApiClient.bs

import "pkg:/source/api/sdk.bs"
import "pkg:/source/api/sdkV1.bs"
import "pkg:/source/api/sdkV2.bs"
import "pkg:/source/utils/misc.bs"

' Note: getApiVersionFromGlobal() is imported from misc.bs and serves as the
' single source of truth for API version detection across the entire app.

' ApiClient provides a centralized, stateful wrapper around the Jellyfin API.
' It automatically injects image parameters and version-specific fields for consistent,
' bulletproof item fetching across all Jellyfin server versions (10.7.0+).
'
' The singleton pattern ensures one instance is shared across the entire app.
class ApiClient
  ' Cache global reference so m.global works inside class methods
  private global = invalid

  sub new()
    m.global = GetGlobalAA().global
  end sub

  ' Default image parameters for all item endpoints
  ' These ensure backdrop, logo, and thumb images are always requested
  private imageDefaults = {
    EnableImageTypes: "Primary,Backdrop,Logo,Thumb",
    ImageTypeLimit: 1
  }

  ' Get current user ID from global state
  private function getUserId() as string
    return m.global.user.id
  end function

  ' Get API version from global state via helper
  ' Uses getApiVersionFromGlobal() as single source of truth for safe API version reading
  private function getApiVersion() as integer
    return getApiVersionFromGlobal()
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' USER ENDPOINTS - Version-aware (V1/V2 routing)
  ' When adding V3 support, add else-if branch in each method below
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Get a single item by ID with automatic image and version field injection
  ' @param itemId - The Jellyfin item ID
  ' Build a request AA to get a single item with automatic image/version field injection.
  ' @param itemId - The item ID
  ' @param params - Optional query parameters (fields, etc.)
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetItemRequest(itemId as string, params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    mergedParams = m.injectDefaults(params)

    if m.getApiVersion() >= 2
      mergedParams.userId = userId
      return m.validatedReq("GET", buildURL(Substitute("/Items/{0}", itemId), mergedParams))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/{1}", userId, itemId), mergedParams))
  end function

  ' Build a request AA to get a single item WITHOUT image/version field injection.
  ' @param itemId - The item ID
  ' @param params - Optional query parameters (passed through as-is)
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetItemRawRequest(itemId as string, params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    if m.getApiVersion() >= 2
      params.userId = userId
      return m.validatedReq("GET", buildURL(Substitute("/Items/{0}", itemId), params))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/{1}", userId, itemId), params))
  end function

  ' Build a request AA for GetItemsByQuery, for use with fetchRes() or fetchJson().
  ' @param params - Query parameters (limit, sortBy, filters, etc.)
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetItemsByQueryRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    mergedParams = m.injectDefaults(params)

    if m.getApiVersion() >= 2
      queryParams = { userId: userId }
      queryParams.append(mergedParams)
      return m.validatedReq("GET", buildURL("/items/", queryParams))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items", userId), mergedParams))
  end function

  ' Build a request AA for GetResumeItems (Continue Watching), for use with fetchRes() or fetchJson().
  ' Mirrors GetResumeItems(): applies injectDefaults, version-aware endpoint.
  ' @param params - Optional query parameters
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetResumeItemsRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    if m.getApiVersion() >= 2
      queryParams = { userId: userId }
      queryParams.append(mergedParams)
      return m.validatedReq("GET", buildURL("/UserItems/Resume", queryParams))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/resume", userId), mergedParams))
  end function

  ' Build a request AA for GetLatestMedia, for use with fetchRes() or fetchJson().
  ' Both V1 and V2 return a bare array of BaseItemDto (not paginated).
  ' V1: /users/{id}/items/latest (10.7.x–10.8.x)
  ' V2: /Items/Latest?userId= (10.9+)
  ' @param params - Optional query parameters (ParentId, Limit, etc.)
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetLatestMediaRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    if m.getApiVersion() >= 2
      queryParams = { userId: userId }
      queryParams.append(mergedParams)
      return m.validatedReq("GET", buildURL("/Items/Latest", queryParams))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/latest", userId), mergedParams))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' RAW ACCESS - No image injection (for playback/internal use)
  ' ═══════════════════════════════════════════════════════════════════════════


  ' ═══════════════════════════════════════════════════════════════════════════
  ' SHOWS ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetSeasons, for use with fetchRes() or fetchJson().
  ' Mirrors GetSeasons(): applies injectDefaults and injects UserId.
  ' @param seriesId - The series ID
  ' @param params - Optional query parameters
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetSeasonsRequest(seriesId as string, params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    mergedParams.UserId = userId
    return m.validatedReq("GET", buildURL(Substitute("/shows/{0}/seasons", seriesId), mergedParams))
  end function

  ' Build a request AA for GetEpisodes, for use with fetchRes() or fetchJson().
  ' @param seriesId - The series ID
  ' @param params - Optional query parameters (StartItemId, Limit, etc.)
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetEpisodesRequest(seriesId as string, params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    mergedParams = m.injectDefaults(params)
    mergedParams.UserId = userId

    return m.validatedReq("GET", buildURL(Substitute("/shows/{0}/episodes", seriesId), mergedParams))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' ARTIST ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetArtistByName, for use with fetchJson().
  ' @param name - Artist name (will be URI-encoded)
  ' @param params - Optional query parameters
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetArtistByNameRequest(name as string, params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("/artists/{0}", name.EncodeUriComponent()), params))
  end function

  ' Build a request AA to fetch all artists, for use with fetchRes() or fetchJson().
  ' Mirrors GetArtists(): applies injectDefaults and injects UserId.
  ' @param params - Query parameters
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetArtistsRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    mergedParams.UserId = userId
    return m.validatedReq("GET", buildURL("/artists", mergedParams))
  end function

  ' Build a request AA for GetPersons, for use with fetchRes() or fetchJson().
  ' Mirrors GetPersons(): applies injectDefaults and injects UserId.
  ' @param params - Query parameters (e.g. IsFavorite, searchTerm)
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetPersonsRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    mergedParams.UserId = userId
    return m.validatedReq("GET", buildURL("/persons", mergedParams))
  end function

  ' Build a request AA to fetch all album artists, for use with fetchRes() or fetchJson().
  ' Mirrors GetAlbumArtists(): applies injectDefaults and injects UserId.
  ' @param params - Query parameters
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetAlbumArtistsRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    mergedParams.UserId = userId
    return m.validatedReq("GET", buildURL("/artists/albumartists", mergedParams))
  end function

  ' Build a request AA to get similar artists, for use with fetchRes() or fetchJson().
  ' @param itemId - The artist item ID
  ' @param params - Optional query parameters (userId, limit, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetArtistSimilarRequest(itemId as string, params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("/Artists/{0}/Similar", itemId), params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' PLAYLIST ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetPlaylistItems, for use with fetchRes() or fetchJson().
  ' @param playlistId - The playlist ID
  ' @param params - Optional query parameters
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetPlaylistItemsRequest(playlistId as string, params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    mergedParams = {}
    mergedParams.append(params)
    mergedParams.UserId = userId

    return m.validatedReq("GET", buildURL(Substitute("/playlists/{0}/items", playlistId), mergedParams))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' ITEMS ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetInstantMix, for use with fetchJson().
  ' @param itemId - The item ID
  ' @param params - Optional query parameters (Limit, etc.)
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetInstantMixRequest(itemId as string, params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    mergedParams = {}
    mergedParams.append(params)
    mergedParams.UserId = userId

    return m.validatedReq("GET", buildURL(Substitute("/items/{0}/instantmix", itemId), mergedParams))
  end function

  ' Build a request AA for GetIntros, for use with fetchJson().
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildGetIntrosRequest(itemId as string) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    if m.getApiVersion() >= 2
      return m.validatedReq("GET", buildURL(Substitute("/Items/{0}/Intros", itemId)))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/{1}/intros", userId, itemId)))
  end function

  ' Build a request AA for GetMediaSegments, for use with fetchJson().
  ' Only available on Jellyfin 10.10.0+ servers.
  ' @param itemId - The item ID
  ' @param includeSegmentTypes - Optional comma-separated segment types to filter (e.g., "Intro,Outro")
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetMediaSegmentsRequest(itemId as string, includeSegmentTypes = "" as string) as dynamic
    params = {}
    if includeSegmentTypes <> ""
      params.includeSegmentTypes = includeSegmentTypes
    end if
    return m.validatedReq("GET", buildURL(Substitute("/MediaSegments/{0}", itemId), params))
  end function

  ' Build a request AA for GetItemLyrics, for use with fetchJson().
  ' @param itemId - The Audio item ID
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetItemLyricsRequest(itemId as string) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("Audio/{0}/Lyrics", itemId)))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' PLAYBACK ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for PostPlaybackInfo, for use with fetchJson().
  ' @param itemId - The item ID
  ' @param postData - Request body data (DeviceProfile, MediaSourceId, etc.)
  ' @returns Request AA: { method, url, body } or invalid if server URL not set
  function BuildPostPlaybackInfoRequest(itemId as string, postData as object) as dynamic
    url = buildURL(Substitute("/items/{0}/playbackinfo", itemId))
    if not isValid(url) then return invalid
    return { method: "POST", url: url, body: FormatJson(postData), headers: { "Content-Type": "application/json" } }
  end function


  ' Build a request AA for GetLocalTrailers, for use with submitApiRequest() or fetchJson().
  ' Version-aware: V2 uses /Items/{id}/LocalTrailers, V1 uses /users/{userId}/items/{id}/localtrailers.
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no userId (V1 only)
  function BuildGetLocalTrailersRequest(itemId as string) as dynamic
    if m.getApiVersion() >= 2
      return m.validatedReq("GET", buildURL(Substitute("/Items/{0}/LocalTrailers", itemId), {}))
    end if
    userId = m.getUserId()
    if userId = "" then return invalid
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/{1}/localtrailers", userId, itemId), {}))
  end function

  ' Build a request AA for GetSpecialFeatures, for use with fetchRes() or fetchJson().
  ' Mirrors GetSpecialFeatures(): version-aware endpoint.
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no userId (V1 only)
  function BuildGetSpecialFeaturesRequest(itemId as string) as dynamic
    if m.getApiVersion() >= 2
      return m.validatedReq("GET", buildURL(Substitute("/Items/{0}/SpecialFeatures", itemId), {}))
    end if
    userId = m.getUserId()
    if userId = "" then return invalid
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/items/{1}/specialfeatures", userId, itemId), {}))
  end function

  ' Build a request AA to mark an item as favorite, for use with SubmitSideEffect().
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildMarkFavoriteRequest(itemId as string) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    if m.getApiVersion() >= 2
      return m.validatedReq("POST", buildURL(Substitute("/UserFavoriteItems/{0}", itemId), { userId: userId }))
    end if
    return m.validatedReq("POST", buildURL(Substitute("users/{0}/favoriteitems/{1}", userId, itemId)))
  end function

  ' Build a request AA to unmark an item as favorite, for use with SubmitSideEffect().
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildUnmarkFavoriteRequest(itemId as string) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    if m.getApiVersion() >= 2
      return m.validatedReq("DELETE", buildURL(Substitute("/UserFavoriteItems/{0}", itemId), { userId: userId }))
    end if
    return m.validatedReq("DELETE", buildURL(Substitute("users/{0}/favoriteitems/{1}", userId, itemId)))
  end function

  ' Build a request AA to mark an item as played, for use with SubmitSideEffect().
  ' Includes default DatePlayed (now) and PlaybackPositionTicks (0) matching Jellyfin API expectations.
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildMarkPlayedRequest(itemId as string) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    params = {
      "DatePlayed": CreateObject("roDateTime").ToISOString(),
      "PlaybackPositionTicks": 0
    }

    if m.getApiVersion() >= 2
      params.userId = userId
      return m.validatedReq("POST", buildURL(Substitute("/UserPlayedItems/{0}", itemId), params))
    end if
    return m.validatedReq("POST", buildURL(Substitute("users/{0}/playeditems/{1}", userId, itemId), params))
  end function

  ' Build a request AA to mark an item as unplayed, for use with SubmitSideEffect().
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url } or invalid if no user
  function BuildUnmarkPlayedRequest(itemId as string) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    if m.getApiVersion() >= 2
      return m.validatedReq("DELETE", buildURL(Substitute("/UserPlayedItems/{0}", itemId), { userId: userId }))
    end if
    return m.validatedReq("DELETE", buildURL(Substitute("users/{0}/playeditems/{1}", userId, itemId)))
  end function

  ' Build a request AA to delete an item, for use with SubmitSideEffect().
  ' @param itemId - The item ID to delete
  ' @returns Request AA: { method, url }
  function BuildDeleteItemRequest(itemId as string) as dynamic
    return m.validatedReq("DELETE", buildURL(Substitute("/items/{0}", itemId)))
  end function



  ' ═══════════════════════════════════════════════════════════════════════════
  ' SESSION ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a playstate request AA for use with SubmitSideEffect().
  ' Applies default playstate fields and routes to the correct session endpoint.
  ' @param state - "start" | "update" | "stop" | "finished"
  ' @param params - Playback parameters (ItemId, PositionTicks, IsPaused, etc.)
  ' @returns Request AA: { method, url, body, headers } or invalid if state unknown
  function BuildPlaystateRequest(state as string, params as object) as dynamic
    merged = {
      IsPaused: false,
      PositionTicks: 0
    }
    for each item in params.items()
      merged[item.key] = item.value
    end for

    path = ""
    if state = "start"
      path = "/sessions/playing"
    else if state = "update"
      path = "/sessions/playing/progress"
    else if state = "stop" or state = "finished"
      path = "/sessions/playing/stopped"
    else
      return invalid
    end if

    url = buildURL(path)
    if not isValid(url) then return invalid
    return {
      method: "POST",
      url: url,
      body: FormatJson(merged),
      headers: { "Content-Type": "application/json" }
    }
  end function

  ' Build a request AA to post full session capabilities, for use with SubmitSideEffect().
  ' @param capabilities - Device capabilities AA from getDeviceCapabilities()
  ' @returns Request AA: { method, url, body, headers } or invalid if server URL not set
  function BuildPostSessionCapabilitiesRequest(capabilities as object) as dynamic
    url = buildURL("/Sessions/Capabilities/Full")
    if not isValid(url) then return invalid
    return {
      method: "POST",
      url: url,
      body: FormatJson(capabilities),
      headers: { "Content-Type": "application/json" }
    }
  end function

  ' Build a request AA to get active sessions, for use with fetchJson().
  ' @param params - Query parameters (e.g. { deviceId: "..." })
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetSessionsRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/sessions", params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' SYSTEM ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Get configuration by name
  ' @param name - Configuration name
  ' @returns API response or invalid on error
  function GetConfigurationByName(name as string) as dynamic
    return sdk.system.GetConfigurationByName(name)
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' BRANDING ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetBrandingConfiguration, for use with fetchRes() or fetchJson().
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetBrandingConfigurationRequest() as dynamic
    return m.validatedReq("GET", buildURL("/branding/configuration"))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' DISPLAY PREFERENCES ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Get display preferences
  ' @param id - Preference ID
  ' @param params - Query parameters
  ' @returns API response or invalid on error
  function GetDisplayPreferences(id as string, params = {} as object) as dynamic
    return sdk.displayPreferences.Get(id, params)
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' AUTHENTICATION ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Authenticate user by username and password
  ' @param username - User's username
  ' @param password - User's password
  ' @returns API response with auth token or invalid on error
  function AuthenticateByName(username as string, password as string) as dynamic
    return sdk.users.AuthenticateByName(username, password)
  end function

  ' Get user by ID
  ' @param userId - The user ID
  ' @returns API response or invalid on error
  function GetUser(userId as string) as dynamic
    return sdk.users.Get(userId)
  end function

  ' Get public users
  ' @returns API response or invalid on error
  function GetPublicUsers() as dynamic
    return sdk.users.GetPublic()
  end function

  ' Authenticate via Quick Connect
  ' @param secret - The Quick Connect secret
  ' @returns API response or invalid on error
  function AuthenticateWithQuickConnect(secret as string) as dynamic
    return sdk.users.AuthenticateWithQuickConnect(secret)
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' QUICK CONNECT ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Initiate Quick Connect
  ' @returns API response with secret or invalid on error
  function InitiateQuickConnect() as dynamic
    return sdk.quickConnect.Initiate()
  end function

  ' Connect via Quick Connect
  ' @param secret - The Quick Connect secret
  ' @returns API response or invalid on error
  function ConnectQuickConnect(secret as string) as dynamic
    return sdk.quickConnect.Connect(secret)
  end function

  ' Build a request AA for GetQuickConnectEnabled, for use with fetchRes() or fetchJson().
  ' Server responds with a plain boolean body (true = enabled, false = disabled).
  ' Lowercase path matches the existing /quickconnect/initiate convention - Jellyfin's
  ' routing is case-insensitive but reverse proxies in the wild sometimes are not.
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetQuickConnectEnabledRequest() as dynamic
    return m.validatedReq("GET", buildURL("/quickconnect/enabled"))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' VIEWS ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetViews, for use with fetchRes() or fetchJson().
  ' Mirrors GetViews(): version-aware endpoint.
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetViewsRequest() as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    if m.getApiVersion() >= 2
      return m.validatedReq("GET", buildURL("/UserViews", { userId: userId }))
    end if
    return m.validatedReq("GET", buildURL(Substitute("/users/{0}/views", userId), {}))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' NEXT UP ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetNextUp, for use with fetchRes() or fetchJson().
  ' Mirrors GetNextUp(): applies injectDefaults and injects UserId.
  ' @param params - Query parameters (SeriesId, limit, EnableRewatching, etc.)
  ' @returns Request AA: { method, url } or invalid if no userId
  function BuildGetNextUpRequest(params = {} as object) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid
    mergedParams = m.injectDefaults(params)
    mergedParams.UserId = userId
    return m.validatedReq("GET", buildURL("/shows/nextup", mergedParams))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' VIDEOS ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetAdditionalParts, for use with fetchRes() or fetchJson().
  ' @param itemId - The video item ID
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetAdditionalPartsRequest(itemId as string) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("/videos/{0}/additionalparts", itemId), {}))
  end function

  ' Build a request AA to get similar items, for use with fetchRes() or fetchJson().
  ' @param itemId - The item ID
  ' @param params - Optional query parameters (userId, limit, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetSimilarItemsRequest(itemId as string, params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("/Items/{0}/Similar", itemId), params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' FILTERS ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for GetFilters, for use with fetchRes() or fetchJson().
  ' @param params - Query parameters (userid, parentid, includeitemtypes, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetFiltersRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/items/filters", params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' IMAGE ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a HEAD request AA to check if an item image exists, for use with fetchRes() or fetchJson().
  ' Caller checks res.ok — no body is returned for HEAD requests.
  ' @param id - Item ID
  ' @param imageType - Type of image (e.g. "logo", "Primary")
  ' @param imageIndex - Image index (default 0)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildHeadItemImageRequest(id as string, imageType as string, imageIndex = 0 as integer) as dynamic
    return m.validatedReq("HEAD", buildURL(Substitute("/items/{0}/images/{1}/{2}", id, imageType, imageIndex.toStr()), {}))
  end function

  ' Get image URL
  ' @param id - Item ID
  ' @param imageType - Type of image
  ' @param imageIndex - Image index
  ' @param params - Optional parameters
  ' @returns API response or invalid on error
  function GetImageURL(id as string, imageType as string, imageIndex = 0 as integer, params = {} as object) as dynamic
    return sdk.items.GetImageURL(id, imageType, imageIndex, params)
  end function

  ' Get user image URL
  ' @param id - User ID
  ' @param imageType - Type of image
  ' @param imageIndex - Image index
  ' @param params - Optional parameters
  ' @returns API response or invalid on error
  function GetUserImageURL(id as string, imageType as string, imageIndex = 0 as integer, params = {} as object) as dynamic
    if m.getApiVersion() >= 2
      return sdkV2.users.GetImageURL(id, imageType, imageIndex, params)
    end if
    return sdkV1.users.GetImageURL(id, imageType, imageIndex, params)
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' LIVETV ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA to fetch Live TV channels, for use with fetchRes() or fetchJson().
  ' @param params - Query parameters
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLiveTVChannelsRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/livetv/channels", params))
  end function

  ' Build a request AA for GetLiveTVPrograms (full EPG search), for use with fetchRes() or fetchJson().
  ' Mirrors GetLiveTVPrograms(): applies injectDefaults.
  ' @param params - Query parameters (SearchTerm, Limit, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLiveTVProgramsRequest(params = {} as object) as dynamic
    mergedParams = m.injectDefaults(params)
    return m.validatedReq("GET", buildURL("/livetv/programs", mergedParams))
  end function

  ' Build a request AA to get recommended Live TV programs (On Now), for use with fetchRes() or fetchJson().
  ' @param params - Query parameters (userId, isAiring, limit, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLiveTvRecommendedProgramsRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/LiveTv/Programs/Recommended", params))
  end function

  ' Build a request AA to get Live TV recordings, for use with fetchRes() or fetchJson().
  ' @param params - Query parameters (isInProgress, limit, status, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLiveTvRecordingsRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/LiveTv/Recordings", params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' STUDIOS ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA to fetch studios, for use with fetchRes() or fetchJson().
  ' @param params - Query parameters
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetStudiosRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/studios", params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' GENRES ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA to fetch genres, for use with fetchRes() or fetchJson().
  ' @param params - Query parameters
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetGenresRequest(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/genres", params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' LIVE TV ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA to fetch a single Live TV program, for use with fetchRes() or fetchJson().
  ' @param programId - The program ID
  ' @param params - Optional query parameters (UserId, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLiveTvProgramRequest(programId as string, params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("/LiveTv/Programs/{0}", programId), params))
  end function

  ' Build a request AA to fetch the Live TV schedule, for use with fetchRes() or fetchJson().
  ' Uses POST because the channelIds list can exceed URL length limits.
  ' @param params - Query/body parameters (channelIds, startTime, endTime, UserId, etc.)
  ' @returns Request AA: { method, url, body, headers } or invalid if server URL not set
  function BuildGetLiveTvScheduleRequest(params as object) as dynamic
    url = buildURL("/LiveTv/Programs")
    if not isValid(url) then return invalid
    return {
      method: "POST",
      url: url,
      body: FormatJson(params),
      headers: { "Content-Type": "application/json" }
    }
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' LIVE TV RECORDING ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA to fetch Live TV timer defaults for a program, for use with fetchRes() or fetchJson().
  ' @param programId - The program ID to pre-fill timer defaults
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLiveTvTimerDefaultsRequest(programId as string) as dynamic
    return m.validatedReq("GET", buildURL("/LiveTv/Timers/Defaults", { programId: programId }))
  end function

  ' Build a request AA to create a Live TV recording timer, for use with fetchRes() or fetchJson().
  ' @param defaults - Timer defaults AA from BuildGetLiveTvTimerDefaultsRequest response
  ' @returns Request AA: { method, url, body, headers } or invalid if server URL not set
  function BuildCreateTimerRequest(defaults as object) as dynamic
    url = buildURL("/LiveTv/Timers")
    if not isValid(url) then return invalid
    return {
      method: "POST",
      url: url,
      body: FormatJson(defaults),
      headers: { "Content-Type": "application/json" }
    }
  end function

  ' Build a request AA to create a Live TV series recording timer, for use with fetchRes() or fetchJson().
  ' @param defaults - Timer defaults AA from BuildGetLiveTvTimerDefaultsRequest response
  ' @returns Request AA: { method, url, body, headers } or invalid if server URL not set
  function BuildCreateSeriesTimerRequest(defaults as object) as dynamic
    url = buildURL("/LiveTv/SeriesTimers")
    if not isValid(url) then return invalid
    return {
      method: "POST",
      url: url,
      body: FormatJson(defaults),
      headers: { "Content-Type": "application/json" }
    }
  end function

  ' Build a request AA to cancel a Live TV recording timer, for use with SubmitSideEffect().
  ' @param timerId - The timer ID to cancel
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildCancelTimerRequest(timerId as string) as dynamic
    return m.validatedReq("DELETE", buildURL(Substitute("/LiveTv/Timers/{0}", timerId)))
  end function

  ' Build a request AA to cancel a Live TV series recording timer, for use with SubmitSideEffect().
  ' @param timerId - The series timer ID to cancel
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildCancelSeriesTimerRequest(timerId as string) as dynamic
    return m.validatedReq("DELETE", buildURL(Substitute("/LiveTv/SeriesTimers/{0}", timerId)))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' PRIVATE HELPERS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Inject default image parameters and version-specific fields into params
  ' Delegates to injectApiParams() pure function for testability
  ' @param params - Original parameters object
  ' @returns New params object with defaults merged in
  private function injectDefaults(params as object) as object
    return injectApiParams(params, m.getApiVersion(), m.imageDefaults)
  end function

  ' Build a request AA with a validated URL from buildURL().
  ' Returns invalid if URL construction failed (e.g. server URL not set).
  ' @param method - HTTP method (GET, POST, DELETE, etc.)
  ' @param url - Result of buildURL() — may be invalid
  ' @returns Request AA: { method, url } or invalid
  private function validatedReq(method as string, url as dynamic) as dynamic
    if not isValid(url) then return invalid
    return { method: method, url: url }
  end function

end class

' ═══════════════════════════════════════════════════════════════════════════
' SINGLETON FACTORY
' ═══════════════════════════════════════════════════════════════════════════

' Get the singleton ApiClient instance
' Creates the instance on first call, returns cached instance thereafter
' Usage: api = GetApi() or data = GetApi().GetItem(id)
' @returns ApiClient singleton instance
function GetApi() as ApiClient
  globalAA = GetGlobalAA()

  if not isValid(globalAA.apiClient)
    globalAA.apiClient = new ApiClient()
  end if

  return globalAA.apiClient
end function