source_utils_nodeHelpers.bs

' Node utility functions for creating and copying SGNodes
namespace nodeHelpers

  ' Compile-time contract for a queue item AA (the return shape of createQueueItem below).
  ' BrighterScript interfaces ARE build-enforced wherever a value is TYPED as the interface
  ' (verified on this toolchain: a QueueItem-typed value raises `cannot-find-name` on an
  ' undeclared member and `return-type-mismatch` on a missing required member). createQueueItem
  ' itself returns `as object`, not `as QueueItem` — its result is used as a real
  ' roAssociativeArray (callers/tests call DoesExist/Count) and an interface return type strips
  ' those AA methods (see the return-type note on the function below). So the ENFORCED gate on the
  ' returned shape is the createQueueItem spec at tests/source/unit/utils/nodeHelpers.spec.bs; this
  ' interface is the single explicit source of truth for the shape, IDE autocomplete, and a
  ' build-checked contract for any local you DO type `as nodeHelpers.QueueItem`. Most members are
  ' `optional`: createQueueItem only copies them when the source carries them, so a bare/lean
  ' source yields a minimal item.
  interface QueueItem
    ' Always present — callers may .Count() the backdrop arrays without an isValid() guard.
    backdropImageTags as object
    parentBackdropImageTags as object
    parentBackdropItemId as string
    ' Identity + playback
    optional id as string
    optional type as string
    optional title as string
    optional mediaSourceId as string
    optional runTimeTicks as longinteger
    ' Now-playing display contract — audio plays IN PLACE, so AudioPlayerView reads these straight
    ' off the queue item (video re-fetches on mount and doesn't need them).
    optional name as string
    optional artists as object
    optional albumArtist as string
    optional albumName as string
    optional albumId as string
    optional albumPrimaryImageTag as string
    optional primaryImageTag as string
    ' Episode navigation (resume dialog)
    optional seriesId as string
    optional seasonId as string
    optional playbackPositionTicks as longinteger
    ' Live TV
    optional channelNumber as string
    ' Set by callers after creation (quickplay / ItemDetails) — playback selection + resume point
    optional startingPoint as longinteger
    optional selectedAudioStreamIndex as integer
    optional selectedSubtitleStreamIndex as integer
  end interface

  ' createQueueItem: Create a queue item AA from a source item.
  '
  ' The queue item is a thin AA — it carries only what the queue infrastructure and the players
  ' need, NOT a full deep copy (that was the original perf win). Video items stay minimal:
  ' LoadVideoContentTask re-fetches all metadata server-side via ItemMetaData() on mount. AUDIO
  ' is different — the audio player advances IN PLACE and AudioPlayerView renders the now-playing
  ' text/poster directly off the queue item, so the display fields (name/artists/albumArtist/
  ' albumName/albumId/image tags) MUST be carried; without them every track change paid a
  ' redundant per-track metadata fetch whose latency popped the artist/album in late.
  '
  ' Accepts either a JellyfinBaseItem node (typed camelCase fields) or a raw Jellyfin API
  ' response AA (PascalCase fields). BrightScript AA lookup is case-insensitive, so field
  ' access works identically for both — e.g. sourceNode.backdropImageTags finds
  ' BackdropImageTags on a raw API AA and backdropImageTags on a typed node.
  '
  ' @param sourceNode - JellyfinBaseItem node or raw Jellyfin API response AA
  ' @returns Associative array with guaranteed field contract, or invalid if sourceNode is invalid
  '
  ' Fields always present in the returned AA (guaranteed contract):
  '   - backdropImageTags: Array of backdrop image tags (empty array = no backdrop)
  '   - parentBackdropImageTags: Array of parent backdrop tags (empty array = none)
  '   - parentBackdropItemId: Parent item ID for parent backdrops (empty string = none)
  '
  ' Fields present only when non-empty / non-zero:
  '   - id: Item identifier (required for playback)
  '   - type: Media type (required for queue routing)
  '   - title: Display title
  '   - mediaSourceId: Media source hint
  '   - runTimeTicks: Runtime duration
  '   - name / artists / albumArtist / albumName / albumId / albumPrimaryImageTag /
  '     primaryImageTag: now-playing display fields (audio plays in place — see header)
  '   - seriesId: Series ID for episode navigation in resume dialog
  '   - seasonId: Season ID for episode navigation in resume dialog
  '   - playbackPositionTicks: Saved resume position
  '
  ' Callers set startingPoint and selectedAudioStreamIndex on the returned AA after creation.
  '
  ' Returns `object`, not `QueueItem`: the queue item is used as a real roAssociativeArray
  ' (callers/tests use DoesExist/Count), and typing it as the interface would strip those AA
  ' methods. The QueueItem interface is the documented, opt-in shape (type a local `as
  ' nodeHelpers.QueueItem` for autocomplete); the createQueueItem spec is the enforced gate.
  function createQueueItem(sourceNode as object) as object
    if not isValid(sourceNode)
      print "[nodeHelpers.createQueueItem] ERROR: sourceNode is invalid"
      return invalid
    end if

    ' Thin AA — only fields the queue infrastructure actually reads
    queueItem = {}

    if isValidAndNotEmpty(sourceNode.id) then queueItem.id = sourceNode.id
    if isValidAndNotEmpty(sourceNode.type) then queueItem.type = sourceNode.type
    if isValidAndNotEmpty(sourceNode.title) then queueItem.title = sourceNode.title
    if isValidAndNotEmpty(sourceNode.mediaSourceId) then queueItem.mediaSourceId = sourceNode.mediaSourceId
    if isValid(sourceNode.runTimeTicks) and sourceNode.runTimeTicks > 0
      queueItem.runTimeTicks = sourceNode.runTimeTicks
    end if

    ' Now-playing display contract (audio) — carried only when present. Queue sources are
    ' mixed-shape: album/playlist/instant-mix queues hold RAW Jellyfin items (PascalCase: Album,
    ' ImageTags.Primary) while single-song taps hold TRANSFORMED items (camelCase: albumName,
    ' primaryImageTag). The rest share a case-insensitive key across both shapes; the two that
    ' diverge read a raw fallback so both source shapes populate the same queue-item contract.
    if isValidAndNotEmpty(sourceNode.name) then queueItem.name = sourceNode.name
    if isValidAndNotEmpty(sourceNode.albumArtist) then queueItem.albumArtist = sourceNode.albumArtist
    if isValid(sourceNode.artists) and sourceNode.artists.count() > 0 then queueItem.artists = sourceNode.artists
    albumName = sourceNode.albumName
    if not isValidAndNotEmpty(albumName) then albumName = sourceNode.album
    if isValidAndNotEmpty(albumName) then queueItem.albumName = albumName
    if isValidAndNotEmpty(sourceNode.albumId) then queueItem.albumId = sourceNode.albumId
    if isValidAndNotEmpty(sourceNode.albumPrimaryImageTag) then queueItem.albumPrimaryImageTag = sourceNode.albumPrimaryImageTag
    primaryImageTag = sourceNode.primaryImageTag
    if not isValidAndNotEmpty(primaryImageTag) and isValid(sourceNode.imageTags) then primaryImageTag = sourceNode.imageTags.primary
    if isValidAndNotEmpty(primaryImageTag) then queueItem.primaryImageTag = primaryImageTag

    ' Episode navigation fields used by the resume dialog handler
    if isValidAndNotEmpty(sourceNode.seriesId) then queueItem.seriesId = sourceNode.seriesId
    if isValidAndNotEmpty(sourceNode.seasonId) then queueItem.seasonId = sourceNode.seasonId

    ' Resume position — stored so the resume dialog can read it without the original node.
    ' Typed nodes: transformer populates playbackPositionTicks from UserData.
    ' Raw API AAs: fall back to UserData.PlaybackPositionTicks (nested).
    positionTicks = 0
    if isValid(sourceNode.playbackPositionTicks) and sourceNode.playbackPositionTicks > 0
      positionTicks = sourceNode.playbackPositionTicks
    else if isValid(sourceNode.UserData) and isValid(sourceNode.UserData.PlaybackPositionTicks)
      positionTicks = sourceNode.UserData.PlaybackPositionTicks
    end if
    if positionTicks > 0 then queueItem.playbackPositionTicks = positionTicks

    ' Backdrop fields — always set to guarantee callers can check .Count() without isValid().
    ' Empty array/string = no backdrop available for this item.
    queueItem.backdropImageTags = isValid(sourceNode.backdropImageTags) ? sourceNode.backdropImageTags : []
    queueItem.parentBackdropImageTags = isValid(sourceNode.parentBackdropImageTags) ? sourceNode.parentBackdropImageTags : []
    queueItem.parentBackdropItemId = isValidAndNotEmpty(sourceNode.parentBackdropItemId) ? sourceNode.parentBackdropItemId : ""

    return queueItem
  end function

end namespace