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