source_api_ApiClient.bs

import "pkg:/source/api/apiResponse.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, put an `if m.getApiVersion() >= 3` branch ahead of each method's
  ' `>= 2` branch below, ending in a `return` (docs/dev/jellyfin-server-versioning.md)
  ' ═══════════════════════════════════════════════════════════════════════════

  ' 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.
  '
  ' The server hardcodes Recursive, OrderBy=DatePlayed and IsResumable on this endpoint, so
  ' MediaTypes is the only narrowing a client controls. Don't bother sending recursive /
  ' SortBy / SortOrder / Filters, they are ignored. Since 12.0 a folder also matches
  ' IsResumable when a descendant is in progress, so an unfiltered query returns Seasons and
  ' Series (by design) mixed in with the episodes. Every JellyRock resume flow is video, so
  ' default to Video; pass MediaTypes as a comma-separated STRING to widen it.
  '
  ' @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)
    ' MediaTypes has to come out a non-empty STRING, not merely present: buildParams()
    ' silently skips roArray values (tech-debt `buildparams-no-array-support`), so an
    ' array here would drop the only filter we control and send the query unnarrowed:
    ' the exact #784 failure, reached through the documented way to widen it.
    callerMediaTypes = mergedParams.MediaTypes
    hasMediaTypes = (type(callerMediaTypes) = "String" or type(callerMediaTypes) = "roString")
    if hasMediaTypes then hasMediaTypes = callerMediaTypes.trim() <> ""
    if not hasMediaTypes then mergedParams.MediaTypes = "Video"
    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.listReq("GET", buildURL("/Items/Latest", queryParams))
    end if
    return m.listReq("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 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().
  ' Applies injectDefaults and injects UserId.
  '
  ' Leaves out the Artist and AlbumArtist kinds unless the caller passes its own
  ' ExcludePersonTypes. From 12.0 the server lists music artists as people (jellyfin#16721),
  ' and the app lists artists from /Artists, so search showed a musician twice. jellyfin-web
  ' sends the same filter, marked to go once artists move to /Persons; drop it here then.
  ' Every supported server accepts the parameter, so it is not version-gated.
  ' @param params - Query parameters (for example 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
    personParams = { "ExcludePersonTypes": "Artist,AlbumArtist" }
    personParams.append(params)
    mergedParams = m.injectDefaults(personParams)
    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 (for example "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.listReq("GET", buildURL(Substitute("/Items/{0}/LocalTrailers", itemId), {}))
    end if
    userId = m.getUserId()
    if userId = "" then return invalid
    return m.listReq("GET", buildURL(Substitute("/users/{0}/items/{1}/localtrailers", userId, itemId), {}))
  end function

  ' Build a request AA for GetItemImageInfos: every image the item has, with each one's
  ' original Width and Height. The same path on every supported server (10.7.0 onward).
  ' @param itemId - The item ID
  ' @returns Request AA: { method, url, expect } or invalid
  function BuildGetItemImageInfosRequest(itemId as string) as dynamic
    return m.listReq("GET", buildURL(Substitute("/Items/{0}/Images", 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.listReq("GET", buildURL(Substitute("/Items/{0}/SpecialFeatures", itemId), {}))
    end if
    userId = m.getUserId()
    if userId = "" then return invalid
    return m.listReq("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 for one item's UserData (position, played, favorite), for use with
  ' fetchRes() or fetchAsync(). Returns a UserItemDataDto, about 150 bytes, where the full
  ' item is kilobytes.
  '
  ' It reads the play state of an ALTERNATE VERSION, which no item query returns on
  ' Jellyfin 12.0 (/Items?ids= filters versions out). Only per-version servers need that,
  ' so callers gate on versionResume.isPerVersionServer(). The route is not in the 10.7.0
  ' floor spec, so a V1 server gets no request rather than a guessed path.
  '
  ' @param itemId - The item (or alternate version) ID
  ' @returns Request AA: { method, url } or invalid if no user or a V1 server
  function BuildGetItemUserDataRequest(itemId as string) as dynamic
    userId = m.getUserId()
    if userId = "" then return invalid

    if m.getApiVersion() >= 2
      return m.validatedReq("GET", buildURL(Substitute("/UserItems/{0}/UserData", itemId), { userId: userId }))
    end if
    return invalid
  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 (for example { deviceId: "..." })
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetSessionsRequest(params = {} as object) as dynamic
    return m.listReq("GET", buildURL("/sessions", params))
  end function

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

  ' ═══════════════════════════════════════════════════════════════════════════
  ' 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
  ' Synchronous, so it bypasses the pool's shape check (listReq); the list is read here instead.
  ' @returns an roArray of UserDto, or invalid on error or a body that holds no list
  function GetPublicUsers() as dynamic
    return apiResponse.listFrom(sdk.users.GetPublic())
  end function

  ' Build a request AA to exchange an APPROVED Quick Connect secret for an auth
  ' token, for use with fetchAsync() / fetchRes().
  '
  ' The body's field name changed at 10.8.0, a FINER boundary than the V1/V2
  ' apiVersion split, so it reads the server version directly rather than
  ' m.getApiVersion():
  '   10.7.x:   { "Token": secret }   (QuickConnectDto.Token, required)
  '   10.8.0+:  { "Secret": secret }  (QuickConnectDto.Secret, required)
  ' Confirmed against every published spec from 10.7.0 to 10.11.8: the rename
  ' lands at exactly 10.8.0, with no version carrying both names.
  '
  ' DON'T BRANCH ON THE ERROR CODE HERE. The spec declares only 400 ("Missing
  ' token") plus 503 from 10.11.0, but a live 10.11.11 answered 404 for a secret
  ' it had not seen approved, an undeclared code. So a non-ok response means
  ' "this secret cannot be exchanged" and nothing finer; quickConnectExchangeFailure
  ' (source/utils/quickConnect.bs) owns the one distinction worth making.
  ' @param secret - The Quick Connect secret from Initiate
  ' @returns Request AA: { method, url, body, headers } or invalid if server URL not set
  function BuildAuthenticateWithQuickConnectRequest(secret as string) as dynamic
    url = buildURL("/users/authenticatewithquickconnect")
    if not isValid(url) then return invalid

    if versionChecker(m.global.server.version, "10.8.0")
      body = { "Secret": secret }
    else
      body = { "Token": secret }
    end if
    return { method: "POST", url: url, body: FormatJson(body), headers: { "Content-Type": "application/json" } }
  end function

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

  ' Build a request AA to start a Quick Connect request, for use with
  ' fetchAsync() / fetchRes(). The response carries the Secret we poll with and
  ' the Code the user types on their other device.
  '
  ' The HTTP METHOD moved at the same boundary as the apiVersion split:
  '   apiVersion 1 (10.7.0 - 10.8.13): GET   (no POST route exists)
  '   apiVersion 2 (10.9.0+):          POST  (no GET route exists)
  ' Confirmed against every published spec from 10.7.0 to 10.11.8: each version
  ' declares exactly one of the two verbs, and the swap is at exactly 10.9.0.
  '
  ' Sending the wrong method to a modern server yields 401 ("Quick connect is not
  ' active on this server"), which reads as a feature gate but is actually a route
  ' mismatch, so the dispatch matters; it is not cosmetic. That collision is
  ' also why quickConnectInitiateFailure treats 401 as "disabled": it is the same
  ' code either way, and a route mismatch is our bug, not something to tell a user.
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildInitiateQuickConnectRequest() as dynamic
    if m.getApiVersion() >= 2
      return m.validatedReq("POST", buildURL("/quickconnect/initiate"))
    end if
    return m.validatedReq("GET", buildURL("/quickconnect/initiate"))
  end function

  ' Build a request AA to poll whether a Quick Connect secret has been approved,
  ' for use with fetchAsync() / fetchRes(). Responds 200 with the same
  ' QuickConnectResult shape Initiate returned, its `Authenticated` flag now true
  ' once someone approved the code.
  '
  ' An UNKNOWN or EXPIRED secret answers 404 ("Unknown quick connect secret"),
  ' declared by every published spec from 10.7.0 to 10.11.8 and corroborated by a
  ' live 10.11.11. That is the only signal separating "not approved yet" from
  ' "this code is dead", and it is invisible to a caller that only parses the
  ' body, which is why this endpoint has to go through the pool (res.statusCode)
  ' rather than a sync JSON helper.
  ' @param secret - The Quick Connect secret from Initiate
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildConnectQuickConnectRequest(secret as string) as dynamic
    return m.validatedReq("GET", buildURL("/quickconnect/connect", { secret: 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

  ' Build a request AA for the collections an item belongs to, for use with fetchRes().
  ' Jellyfin 12.0+ only, so there is no V1 path: older servers answer 404. Decide with
  ' extrasRows.supportsItemCollections() before building it.
  ' @param itemId - The item ID
  ' @param params - Optional query parameters (userId, Fields, limit, startIndex)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetItemCollectionsRequest(itemId as string, params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL(Substitute("/Items/{0}/Collections", itemId), params))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' SUBTITLE ENDPOINTS
  '
  ' Server-side subtitle search / download / delete (issue #750). All three
  ' routes exist unchanged at the 10.7.0 floor, so there is NO V1/V2 branching
  ' here: verified against the spec fingerprints for both 10.7.0 and 10.11.8.
  '
  ' The PERMISSION model, however, IS version-dependent, and that gate lives in
  ' remoteSubtitles.bs (canSearchSubtitles / canDeleteSubtitles) rather than
  ' here, because it is pure and unit-testable. Summary of what the server
  ' enforces (measured on a live 10.11.11 server, see remoteSubtitles.bs):
  '   * search + download: DefaultAuthorization on 10.7-10.8 (any user);
  '     Policies.SubtitleManagement on 10.9+ (admins bypass it).
  '   * delete: Policies.RequiresElevation (admin) on every version.
  '
  ' Search is only meaningful for Movie and Episode. The server returns an
  ' empty array for any other Video type, and 404 for a non-Video item (for example a
  ' BoxSet), because the controller resolves the item as GetItemById<Video>.
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA to search a server's configured subtitle providers, for
  ' use with fetchRes() or fetchJson(). Returns an array of RemoteSubtitleInfo.
  '
  ' `language` is passed straight through to the server, which resolves it via
  ' LocalizationManager.FindLanguageInfo(), which matches case-insensitively on
  ' DisplayName, Name, TwoLetterISOLanguageName, OR either ISO 639-2 variant
  ' (/T and /B both live in ThreeLetterISOLanguageNames). So "fra", "fre", "fr"
  ' and "French" are all accepted. Prefer the TWO-letter form from
  ' /Localization/Cultures: it is what reaches the provider, and it preserves
  ' regional variants that the 3-letter form collapses (zh-tw / zh-cn / zh-hk
  ' all share "zho"; es-419 shares "spa" with Castilian).
  '
  ' @param itemId - The Movie or Episode ID
  ' @param language - Language code (see above; 2-letter or regional preferred)
  ' @param isPerfectMatch - Restrict to file-hash matches only. Defaults false;
  '                         a true value returns only results whose IsHashMatch
  '                         is set, which is frequently an empty list.
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildSearchRemoteSubtitlesRequest(itemId as string, language as string, isPerfectMatch = false as boolean) as dynamic
    params = {}
    if isPerfectMatch then params.isPerfectMatch = "true"
    return m.listReq("GET", buildURL(Substitute("/Items/{0}/RemoteSearch/Subtitles/{1}", itemId, language), params))
  end function

  ' Build a request AA to have the SERVER download a remote subtitle onto the
  ' item, for use with fetchRes() (not SubmitSideEffect: see below).
  '
  ' The response carries NO success information: the controller wraps the
  ' download in try/catch, logs any failure, and returns 204 either way, then
  ' queues the metadata refresh ASYNCHRONOUSLY (QueueRefresh). A missing
  ' provider, absent credentials, and an exhausted daily quota all yield the
  ' same 204 as a success. The only honest completion signal is re-fetching the
  ' item and diffing its subtitle MediaStreams: see newSubtitleStreams().
  '
  ' @param itemId - The Movie or Episode ID
  ' @param subtitleId - RemoteSubtitleInfo.Id from a search result
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildDownloadRemoteSubtitleRequest(itemId as string, subtitleId as string) as dynamic
    return m.validatedReq("POST", buildURL(Substitute("/Items/{0}/RemoteSearch/Subtitles/{1}", itemId, subtitleId)))
  end function

  ' Build a request AA to delete an EXTERNAL subtitle file from an item, for
  ' use with fetchRes().
  '
  ' It takes the stream, not an index, and that is the point. The server deletes
  ' MediaStream.Path unconditionally, and for an EMBEDDED stream that path is
  ' the media container, so aiming this at the wrong stream destroys the
  ' user's movie, not a subtitle. This used to accept a bare integer and warn
  ' the caller in prose to check IsExternal first. Prose is not a guard: the
  ' subtitle panel's list was ALSO external-only at the time, so the safety
  ' rested on two separate places staying in agreement, and the moment that
  ' list grew to include embedded streams (so it could describe the item
  ' honestly) the only thing standing between a mis-wired row and a deleted
  ' container was a comment. Requiring the stream means the check happens here,
  ' once, and cannot be forgotten by a future caller.
  '
  ' A non-external stream returns INVALID rather than raising: callers already
  ' treat an invalid request as "cannot do this" (validatedReq does the same
  ' when no server URL is set), so refusing degrades into an inert button
  ' instead of a crash.
  '
  ' The one hazard left with the caller: Index must come from a FRESH item
  ' fetch. A download inserts a subtitle at index 0 and renumbers every other
  ' stream, so a stale index deletes the wrong file, or errors (observed as
  ' HTTP 400) if it now points past the end.
  '
  ' Unlike download, delete is SYNCHRONOUS end-to-end: the controller awaits
  ' RefreshMetadata, so the next GET /Items/{id} already reflects the new
  ' stream list (verified: correct at t+0s).
  '
  ' @param itemId - The video item ID
  ' @param stream - The subtitle MediaStream AA to delete; must be IsExternal
  ' @returns Request AA: { method, url }, or invalid if the stream is not an
  '          external subtitle, has no index, or the server URL is not set
  function BuildDeleteSubtitleRequest(itemId as string, stream as dynamic) as dynamic
    if not isValid(stream) then return invalid
    ' Explicitly true: an absent IsExternal must read as "not safe", never as
    ' "probably fine".
    if stream.IsExternal <> true then return invalid
    if not isValid(stream.Index) then return invalid

    return m.validatedReq("DELETE", buildURL(Substitute("/Videos/{0}/Subtitles/{1}", itemId, stream.Index.toStr())))
  end function

  ' ═══════════════════════════════════════════════════════════════════════════
  ' LOCALIZATION ENDPOINTS
  ' ═══════════════════════════════════════════════════════════════════════════

  ' Build a request AA for the server's culture list, for use with fetchRes() or
  ' fetchJson(). Returns an array of CultureDto.
  '
  ' Worth caching for the session: it is static per server (192 entries on
  ' 10.11.11) and is only needed to populate a language picker.
  '
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetCulturesRequest() as dynamic
    return m.listReq("GET", buildURL("/Localization/Cultures"))
  end function

  ' Build a request AA for the server's library options, for use with fetchRes()
  ' or fetchJson(). Returns a LibraryOptionsResultDto.
  '
  ' Used to learn which metadata PLUGINS the server has installed (SubtitleFetchers
  ' lists every installed subtitle provider by name), so a feature that needs one
  ' can be hidden rather than offered and then fail empty. Read by
  ' source/utils/serverCapabilities.bs.
  '
  ' Unchanged from 10.7.0 through 12.0, and authorized for ANY signed-in user
  ' (Policies.FirstTimeSetupOrDefault), so it needs no V1/V2 branch and no admin.
  ' libraryContentType is omitted on purpose: the server then reports plugins for
  ' Series, Season, Episode and Movie, exactly the types subtitle search supports.
  '
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetLibraryOptionsInfoRequest() as dynamic
    return m.validatedReq("GET", buildURL("/Libraries/AvailableOptions"))
  end function

  ' Build a request AA for the server configuration, for use with fetchRes() or
  ' fetchAsync(). Returns a ServerConfiguration.
  '
  ' Read for MaxResumePct: the percentage past which the server records playback as
  ' finished instead of storing a resume position (source/utils/versionResume.bs).
  ' Authorized for ANY signed-in user, not only admins: a non-admin account got 200 on
  ' 10.7.7, 10.10.7, 10.11.11 and 12.0.0.
  '
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetServerConfigurationRequest() as dynamic
    return m.validatedReq("GET", buildURL("/System/Configuration"))
  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

  ' Build a request AA for GetFilters2, for use with fetchRes() or fetchJson().
  '
  ' A SECOND filters endpoint, not a replacement: the two return disjoint sets.
  ' `/items/filters` carries OfficialRatings and Years, which this one does not;
  ' this one carries AudioLanguages and SubtitleLanguages (12.0+), which that one
  ' does not. A caller wanting both has to ask twice, verified against the
  ' 10.11.11 and 12.0.0 OpenAPI specs, where `QueryFiltersLegacy` and
  ' `QueryFilters` are separate schemas.
  '
  ' On servers below 12.0 the response omits the language keys entirely, so their
  ' absence is a usable capability probe rather than an empty-list ambiguity.
  '
  ' @param params - Query parameters (userid, parentid, includeitemtypes, etc.)
  ' @returns Request AA: { method, url } or invalid if server URL not set
  function BuildGetFilters2Request(params = {} as object) as dynamic
    return m.validatedReq("GET", buildURL("/items/filters2", 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 (for example "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
    ' A POST that only reads, so the pool may skip it once its caller is gone
    ' (apiPool.bs, "Skipping a request nobody is waiting for").
    return {
      method: "POST",
      url: url,
      body: FormatJson(params),
      headers: { "Content-Type": "application/json" },
      skippable: true
    }
  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

  ' Build a request AA to close a live stream the server opened for PlaybackInfo, for use with
  ' SubmitSideEffect(). Each close releases one consumer; the server closes the stream when none
  ' remain. The same endpoint and query on every supported server (10.7.0 through 12.x).
  ' @param liveStreamId - The media source's LiveStreamId
  ' @returns Request AA: { method, url } or invalid if the id is empty or no server URL is set
  function BuildCloseLiveStreamRequest(liveStreamId as string) as dynamic
    if liveStreamId = "" then return invalid
    return m.validatedReq("POST", buildURL("/LiveStreams/Close", { liveStreamId: liveStreamId }))
  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 (for example 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

  ' validatedReq for an endpoint whose spec response is a bare ARRAY. It marks the request so the
  ' pool holds the body to that shape (apiResponse.enforceShape): an ok response then always
  ' carries an roArray in `json`, and a body that holds no list arrives as not-ok. Every builder
  ' for such an endpoint must use this: an object body that reaches a caller iterates as its
  ' keys, and the first field read ends the app.
  ' @param method - HTTP method
  ' @param url - Result of buildURL(); may be invalid
  ' @returns Request AA: { method, url, expect: "list" } or invalid
  private function listReq(method as string, url as dynamic) as dynamic
    req = m.validatedReq(method, url)
    if isValid(req) then req.expect = apiResponse.EXPECT_LIST
    return req
  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