source_data_JellyfinDataTransformer.bs
import "pkg:/source/api/Image.bs"
import "pkg:/source/utils/misc.bs"
' Transforms Jellyfin API responses to JellyfinBaseItem content nodes.
' Follows the type defaults pattern: fields have XML-declared defaults ("", 0, false).
' See JellyfinBaseItem.xml for field documentation and the full defaults pattern description.
class JellyfinDataTransformer
' Cache global reference so m.global works inside class methods
private global = invalid
sub new()
m.global = GetGlobalAA().global
end sub
' Transform a BaseItemDto to a JellyfinBaseItem content node.
' @param apiData - Raw BaseItemDto assocarray from the Jellyfin API
' @param serverVersion - Server version string (auto-detected from global session if omitted)
' @param preferredSubtitleLanguage - BCP-47 language code for subtitle sort priority (e.g. "eng")
' @returns JellyfinBaseItem node, or invalid if apiData is invalid
function transformBaseItem(apiData as object, serverVersion = "" as string, preferredSubtitleLanguage = "" as string) as object
if not isValid(apiData)
return invalid
end if
' Get server version from global session if not provided
if not isValidAndNotEmpty(serverVersion)
version = m.global.server.version
if version <> ""
serverVersion = version
else
serverVersion = "unknown"
end if
end if
try
item = CreateObject("roSGNode", "JellyfinBaseItem")
' ── Core identity ──
item.id = apiData.Id ?? ""
item.type = apiData.Type ?? ""
item.name = apiData.Name ?? ""
' Mirror name into ContentNode's built-in title field so RSG list components (LabelList,
' RowList, etc.) can consume JellyfinBaseItem nodes directly without render-thread mapping.
item.title = item.name
item.originalTitle = apiData.OriginalTitle ?? ""
item.sortName = apiData.SortName ?? ""
item.etag = apiData.Etag ?? ""
item.mediaType = apiData.MediaType ?? ""
item.extraType = apiData.ExtraType ?? ""
item.locationType = apiData.LocationType ?? ""
item.sourceType = apiData.SourceType ?? ""
item.playlistItemId = apiData.PlaylistItemId ?? ""
item.displayOrder = apiData.DisplayOrder ?? ""
item.isFolder = apiData.IsFolder ?? false
' folderType only applies to folder items; leave at XML default for non-folders.
' Resolved design decision: Genre/MusicGenre/Studio items are folder sub-types —
' keep item.type = "Folder" for routing and store the real API type in folderType.
' CollectionFolder and plain Folder items use CollectionType (e.g. "movies", "music").
if isValid(apiData.IsFolder) and apiData.IsFolder = true
apiItemType = apiData.Type ?? ""
if apiItemType = "Genre" or apiItemType = "MusicGenre" or apiItemType = "Studio"
item.type = "Folder"
item.folderType = apiItemType
else
item.folderType = apiData.CollectionType ?? ""
end if
end if
item.isPlaceHolder = apiData.IsPlaceHolder ?? false
item.path = apiData.Path ?? ""
' ── Parent/Container ──
item.serverId = apiData.ServerId ?? ""
item.parentId = apiData.ParentId ?? ""
item.channelId = apiData.ChannelId ?? ""
item.collectionType = apiData.CollectionType ?? ""
' ── Dates & Timing ──
item.premiereDate = apiData.PremiereDate ?? ""
item.startDate = apiData.StartDate ?? ""
item.endDate = apiData.EndDate ?? ""
item.dateCreated = apiData.DateCreated ?? ""
item.dateLastMediaAdded = apiData.DateLastMediaAdded ?? ""
item.airTime = apiData.AirTime ?? ""
if isValid(apiData.AirDays)
item.airDays = apiData.AirDays
end if
' ── Media Metadata ──
item.overview = apiData.Overview ?? ""
if isValid(apiData.Taglines)
item.taglines = apiData.Taglines
end if
item.runTimeTicks = apiData.RunTimeTicks ?? 0
item.cumulativeRunTimeTicks = apiData.CumulativeRunTimeTicks ?? 0
item.productionYear = apiData.ProductionYear ?? 0
if isValid(apiData.ProductionLocations)
item.productionLocations = apiData.ProductionLocations
end if
item.videoType = apiData.VideoType ?? ""
item.aspectRatio = apiData.AspectRatio ?? ""
item.isHD = apiData.IsHD ?? false
item.hasSubtitles = apiData.HasSubtitles ?? false
item.status = apiData.Status ?? ""
' ── Ratings ──
item.officialRating = apiData.OfficialRating ?? ""
item.customRating = apiData.CustomRating ?? ""
item.communityRating = apiData.CommunityRating ?? 0.0
item.criticRating = apiData.CriticRating ?? 0.0
' ── Genres, Studios, Tags ──
if isValid(apiData.Genres)
item.genres = apiData.Genres
end if
if isValid(apiData.GenreItems)
item.genreItems = apiData.GenreItems
end if
if isValid(apiData.Studios)
item.studios = m.extractNamedItemNames(apiData.Studios)
item.studioItems = m.extractNamedItems(apiData.Studios)
end if
if isValid(apiData.Tags)
item.tags = apiData.Tags
end if
' ── People ──
if isValid(apiData.People)
item.people = apiData.People
end if
' ── External IDs & Links ──
if isValid(apiData.ProviderIds)
item.providerIds = apiData.ProviderIds
end if
if isValid(apiData.ExternalUrls)
item.externalUrls = apiData.ExternalUrls
end if
if isValid(apiData.RemoteTrailers)
item.remoteTrailers = apiData.RemoteTrailers
end if
' ── Permissions ──
item.canDelete = apiData.CanDelete ?? false
item.canDownload = apiData.CanDownload ?? false
' ── Child/Item Counts ──
if isValid(apiData.ChildCount)
item.childCount = apiData.ChildCount
end if
if isValid(apiData.RecursiveItemCount)
item.recursiveItemCount = apiData.RecursiveItemCount
end if
if isValid(apiData.SpecialFeatureCount)
item.specialFeatureCount = apiData.SpecialFeatureCount
end if
if isValid(apiData.LocalTrailerCount)
item.localTrailerCount = apiData.LocalTrailerCount
end if
if isValid(apiData.TrailerCount)
item.trailerCount = apiData.TrailerCount
end if
if isValid(apiData.MovieCount)
item.movieCount = apiData.MovieCount
end if
if isValid(apiData.SeriesCount)
item.seriesCount = apiData.SeriesCount
end if
if isValid(apiData.EpisodeCount)
item.episodeCount = apiData.EpisodeCount
end if
if isValid(apiData.SongCount)
item.songCount = apiData.SongCount
end if
if isValid(apiData.AlbumCount)
item.albumCount = apiData.AlbumCount
end if
if isValid(apiData.MusicVideoCount)
item.musicVideoCount = apiData.MusicVideoCount
end if
if isValid(apiData.ProgramCount)
item.programCount = apiData.ProgramCount
end if
if isValid(apiData.PartCount)
item.partCount = apiData.PartCount
end if
if isValid(apiData.MediaSourceCount)
item.mediaSourceCount = apiData.MediaSourceCount
end if
' ── Type-specific fields ──
itemType = item.type
if itemType = "Episode"
m.transformEpisodeFields(item, apiData)
else if itemType = "Season"
m.transformSeasonFields(item, apiData)
else if itemType = "Series"
m.transformSeriesFields(item, apiData)
else if itemType = "TvChannel"
' TV channels are always live — set the ContentNode live built-in so applyLiveDirectPlayFallback
' can arm the transcode retry path, and isLive in the JellyfinBaseItem field for consistency.
item.isLive = true
item.live = true
' Channel number lives in apiData.Number for TvChannel items (distinct from apiData.ChannelNumber,
' which is the program-to-channel reference field used only on Program/Recording items).
item.channelNumber = apiData.Number ?? ""
' CurrentProgram is a nested BaseItemDto returned by default on all TvChannel items
' (not gated behind ItemFields — no extra fields= param needed). Transform it so the
' OSD and playback stack can read typed program fields (name, episode info, artwork,
' runtime, end time) directly from the TvChannel node.
if isValid(apiData.CurrentProgram)
programNode = m.transformBaseItem(apiData.CurrentProgram, serverVersion)
if isValid(programNode)
item.currentProgram = programNode
end if
end if
else if itemType = "Program" or itemType = "Recording"
m.transformProgramFields(item, apiData)
else if itemType = "MusicAlbum" or itemType = "Audio" or itemType = "MusicArtist" or itemType = "MusicVideo"
m.transformMusicFields(item, apiData)
end if
' ── User data (flattened for performance; fields stay invalid if UserData absent) ──
m.transformUserDataFields(item, apiData.UserData)
' ── Image tags (URLs generated on-demand by itemImageUrl utility) ──
m.transformImageTags(item, apiData)
' ── Media sources & streams ──
if isValid(apiData.MediaSources) and apiData.MediaSources.Count() > 0
m.transformMediaSources(item, apiData.MediaSources, preferredSubtitleLanguage)
end if
' ── Chapters ──
if isValid(apiData.Chapters)
m.transformChapters(item, apiData.Chapters)
end if
' ── Trickplay ──
if isValid(apiData.Trickplay)
m.transformTrickplay(item, apiData.Trickplay)
end if
' ── Media attachments ──
if isValid(apiData.MediaAttachments)
m.transformMediaAttachments(item, apiData.MediaAttachments)
end if
' ── Pre-compute subtitle display text ──
item.subtitle = m.computeSubtitle(apiData)
' ── Metadata ──
item.serverVersion = serverVersion
#if debug
item.rawApiData = apiData
#end if
item.transformedAt = CreateObject("roDateTime").AsSeconds()
return item
catch error
print "[JellyfinDataTransformer] transformBaseItem error: "; error.message
return invalid
end try
end function
' Transform an array of BaseItemDto objects.
' Skips invalid entries silently.
' @param apiArray - Array of BaseItemDto assocArrays
' @param serverVersion - Server version (auto-detected if omitted)
' @param preferredSubtitleLanguage - Language code for subtitle sort preference
' @returns Array of JellyfinBaseItem nodes (may be shorter than input if entries were invalid)
function transformBaseItemArray(apiArray as object, serverVersion = "" as string, preferredSubtitleLanguage = "" as string) as object
if not isValid(apiArray) or apiArray.Count() = 0
return []
end if
' Resolve server version once for all items
if not isValidAndNotEmpty(serverVersion)
version = m.global.server.version
if version <> ""
serverVersion = version
else
serverVersion = "unknown"
end if
end if
results = []
for each apiItem in apiArray
transformedItem = m.transformBaseItem(apiItem, serverVersion, preferredSubtitleLanguage)
if isValid(transformedItem)
results.push(transformedItem)
end if
end for
return results
end function
' Populate media segments from a separate API call result.
' This is a sanctioned post-transformation mutation: data arrives via a separate API call.
' @param item - JellyfinBaseItem node (already transformed)
' @param segmentsResponse - Response body from GET /MediaSegments/{itemId}
sub populateMediaSegments(item as object, segmentsResponse as object)
if not isValid(item) or not isValid(segmentsResponse)
return
end if
segments = segmentsResponse.Items ?? []
item.mediaSegments = segments
hasIntro = false
hasOutro = false
for each segment in segments
segType = segment.Type ?? ""
if segType = "Intro"
hasIntro = true
else if segType = "Outro"
hasOutro = true
end if
end for
item.hasIntro = hasIntro
item.hasOutro = hasOutro
end sub
' Populate theme songs from a separate API call result.
' @param item - JellyfinBaseItem node (already transformed)
' @param response - Response from GET /Items/{id}/ThemeSongs
sub populateThemeSongs(item as object, response as object)
if not isValid(item) or not isValid(response)
return
end if
item.themeSongs = response.Items ?? []
end sub
' Populate theme videos from a separate API call result.
' @param item - JellyfinBaseItem node (already transformed)
' @param response - Response from GET /Items/{id}/ThemeVideos
sub populateThemeVideos(item as object, response as object)
if not isValid(item) or not isValid(response)
return
end if
item.themeVideos = response.Items ?? []
end sub
' Populate lyrics from a separate API call result.
' @param item - JellyfinBaseItem node (already transformed)
' @param response - LyricDto response from GET /Audio/{itemId}/Lyrics
sub populateLyrics(item as object, response as object)
if not isValid(item) or not isValid(response)
return
end if
item.lyricsData = response
end sub
' ── Private helpers ──────────────────────────────────────────────────────────
' Transform user data fields onto item (flattened for performance).
' If UserData is absent from the API response, all user data fields stay at their XML defaults (false, 0, "").
private sub transformUserDataFields(item as object, userData as object)
if not isValid(userData)
return
end if
item.playbackPositionTicks = userData.PlaybackPositionTicks ?? 0
item.playedPercentage = userData.PlayedPercentage ?? 0.0
item.isWatched = userData.Played ?? false
item.isFavorite = userData.IsFavorite ?? false
item.playCount = userData.PlayCount ?? 0
item.lastPlayedDate = userData.LastPlayedDate ?? ""
item.userRating = userData.Rating ?? 0.0
item.playAccess = userData.PlayAccess ?? ""
' Likes: only set when explicitly present (null = no opinion → stays at XML default false)
if isValid(userData.Likes)
item.userLikes = userData.Likes
end if
' Derived: resumable if has position and not fully watched
item.isResumable = (item.playbackPositionTicks > 0) and (not item.isWatched)
' Unplayed count for series/seasons
if isValid(userData.UnplayedItemCount)
item.unplayedItemCount = userData.UnplayedItemCount
end if
end sub
' Transform episode-specific fields.
private sub transformEpisodeFields(item as object, apiData as object)
item.seriesId = apiData.SeriesId ?? ""
item.seriesName = apiData.SeriesName ?? ""
item.seasonId = apiData.SeasonId ?? ""
item.seasonName = apiData.SeasonName ?? ""
item.episodeTitle = apiData.EpisodeTitle ?? ""
item.indexNumber = apiData.IndexNumber ?? 0
item.parentIndexNumber = apiData.ParentIndexNumber ?? 0
if isValid(apiData.IndexNumberEnd)
item.indexNumberEnd = apiData.IndexNumberEnd
end if
if isValid(apiData.AirsBeforeSeasonNumber)
item.airsBeforeSeasonNumber = apiData.AirsBeforeSeasonNumber
end if
if isValid(apiData.AirsAfterSeasonNumber)
item.airsAfterSeasonNumber = apiData.AirsAfterSeasonNumber
end if
if isValid(apiData.AirsBeforeEpisodeNumber)
item.airsBeforeEpisodeNumber = apiData.AirsBeforeEpisodeNumber
end if
end sub
' Transform season-specific fields.
private sub transformSeasonFields(item as object, apiData as object)
item.seriesId = apiData.SeriesId ?? ""
item.seriesName = apiData.SeriesName ?? ""
item.indexNumber = apiData.IndexNumber ?? 0
if isValid(apiData.SeriesStudio)
item.seriesStudio = apiData.SeriesStudio
end if
end sub
' Transform series-specific fields.
private sub transformSeriesFields(item as object, apiData as object)
item.seriesStudio = apiData.SeriesStudio ?? ""
item.status = apiData.Status ?? ""
if isValid(apiData.AirDays)
item.airDays = apiData.AirDays
end if
item.airTime = apiData.AirTime ?? ""
end sub
' Transform program/recording (Live TV) specific fields.
private sub transformProgramFields(item as object, apiData as object)
item.channelName = apiData.ChannelName ?? ""
item.channelNumber = apiData.ChannelNumber ?? ""
item.channelType = apiData.ChannelType ?? ""
item.isLive = apiData.IsLive ?? false
item.isNews = apiData.IsNews ?? false
item.isSports = apiData.IsSports ?? false
item.isKids = apiData.IsKids ?? false
item.isPremiere = apiData.IsPremiere ?? false
item.isRepeat = apiData.IsRepeat ?? false
item.isMovie = apiData.IsMovie ?? false
item.isSeries = apiData.IsSeries ?? false
item.audio = apiData.Audio ?? ""
item.programId = apiData.ProgramId ?? ""
item.seriesTimerId = apiData.SeriesTimerId ?? ""
item.timerId = apiData.TimerId ?? ""
if isValid(apiData.CompletionPercentage)
item.completionPercentage = apiData.CompletionPercentage
end if
' Episode/series context within a program
item.seriesId = apiData.SeriesId ?? ""
item.seriesName = apiData.SeriesName ?? ""
item.seasonId = apiData.SeasonId ?? ""
item.episodeTitle = apiData.EpisodeTitle ?? ""
if isValid(apiData.IndexNumber)
item.indexNumber = apiData.IndexNumber
end if
if isValid(apiData.ParentIndexNumber)
item.parentIndexNumber = apiData.ParentIndexNumber
end if
' ── ContentNode built-ins for Roku TimeGrid positioning ──
' TimeGrid reads PlayStart (epoch seconds) and PlayDuration (seconds) from ContentNode.
' Programs use StartDate/EndDate for broadcast times (distinct from media PremiereDate).
if isValidAndNotEmpty(item.startDate)
startDt = createObject("roDateTime")
startDt.FromISO8601String(item.startDate)
item.PlayStart = startDt.AsSeconds()
if isValidAndNotEmpty(item.endDate)
endDt = createObject("roDateTime")
endDt.FromISO8601String(item.endDate)
item.PlayDuration = endDt.AsSeconds() - item.PlayStart
end if
end if
' ContentNode description built-in (Roku standard), used by ProgramDetails overview display
item.live = item.isLive
end sub
' Transform music-specific fields (Audio, MusicAlbum, MusicArtist, MusicVideo).
private sub transformMusicFields(item as object, apiData as object)
item.albumArtist = apiData.AlbumArtist ?? ""
item.albumName = apiData.Album ?? ""
item.albumId = apiData.AlbumId ?? ""
if isValid(apiData.Artists)
item.artists = apiData.Artists
end if
if isValid(apiData.ArtistItems)
item.artistItems = apiData.ArtistItems
end if
if isValid(apiData.AlbumArtists)
item.albumArtists = apiData.AlbumArtists
end if
if isValid(apiData.NormalizationGain)
item.normalizationGain = apiData.NormalizationGain
end if
item.hasLyrics = apiData.HasLyrics ?? false
' Track/disc position
if isValid(apiData.IndexNumber)
item.indexNumber = apiData.IndexNumber
end if
if isValid(apiData.ParentIndexNumber)
item.parentIndexNumber = apiData.ParentIndexNumber
end if
end sub
' Store image tags only - URLs are generated on-demand by the itemImageUrl utility.
private sub transformImageTags(item as object, apiData as object)
if isValid(apiData.ImageTags)
item.primaryImageTag = apiData.ImageTags.Primary ?? ""
item.thumbImageTag = apiData.ImageTags.Thumb ?? ""
item.logoImageTag = apiData.ImageTags.Logo ?? ""
end if
if isValid(apiData.PrimaryImageAspectRatio)
item.primaryImageAspectRatio = apiData.PrimaryImageAspectRatio
end if
if isValid(apiData.BackdropImageTags) and apiData.BackdropImageTags.Count() > 0
item.backdropImageTags = apiData.BackdropImageTags
end if
if isValid(apiData.ScreenshotImageTags) and apiData.ScreenshotImageTags.Count() > 0
item.screenshotImageTags = apiData.ScreenshotImageTags
end if
if isValid(apiData.ImageBlurHashes)
item.imageBlurHashes = apiData.ImageBlurHashes
end if
' Parent/fallback image tags (for episodes, seasons, music, etc.)
item.parentPrimaryImageTag = apiData.ParentPrimaryImageTag ?? ""
item.parentPrimaryImageItemId = apiData.ParentPrimaryImageItemId ?? ""
item.parentThumbImageTag = apiData.ParentThumbImageTag ?? ""
item.parentThumbItemId = apiData.ParentThumbItemId ?? ""
item.parentLogoImageTag = apiData.ParentLogoImageTag ?? ""
item.parentLogoItemId = apiData.ParentLogoItemId ?? ""
item.parentArtImageTag = apiData.ParentArtImageTag ?? ""
item.parentArtItemId = apiData.ParentArtItemId ?? ""
if isValid(apiData.ParentBackdropImageTags) and apiData.ParentBackdropImageTags.Count() > 0
item.parentBackdropImageTags = apiData.ParentBackdropImageTags
item.parentBackdropItemId = apiData.ParentBackdropItemId ?? ""
end if
item.seriesPrimaryImageTag = apiData.SeriesPrimaryImageTag ?? ""
item.seriesThumbImageTag = apiData.SeriesThumbImageTag ?? ""
item.channelPrimaryImageTag = apiData.ChannelPrimaryImageTag ?? ""
item.albumPrimaryImageTag = apiData.AlbumPrimaryImageTag ?? ""
end sub
' Process MediaSources array: normalize container, filter/sort streams, set counts.
' @param item - JellyfinBaseItem node being populated
' @param mediaSources - MediaSources array from the API response
' @param preferredSubtitleLanguage - BCP-47 language code for subtitle sort priority
private sub transformMediaSources(item as object, mediaSources as object, preferredSubtitleLanguage as string)
' Store raw data for full playback info access (source switching, etc.)
item.mediaSourcesData = { mediaSources: mediaSources }
item.hasMultipleVideoSources = mediaSources.Count() > 1
' Use the first (default) media source for primary metadata
firstSource = mediaSources[0]
if not isValid(firstSource)
return
end if
item.mediaSourceId = firstSource.Id ?? ""
item.mediaSourceProtocol = firstSource.Protocol ?? ""
item.supportsDirectPlay = firstSource.SupportsDirectPlay ?? false
' NOTE: Call module-level normalizeContainer(), not m.normalizeContainer().
' BrightScript does not reliably propagate `m` (the class instance) into private subs
' invoked via method dispatch. Module-level functions avoid this entirely.
item.container = JDT_normalizeContainer(firstSource.Container ?? "")
if not isValid(firstSource.MediaStreams)
return
end if
videoStreams = []
audioStreams = []
rawSubtitleStreams = []
for each stream in firstSource.MediaStreams
streamType = stream.Type ?? ""
if streamType = "Video"
videoStreams.push(stream)
else if streamType = "Audio"
audioStreams.push(stream)
else if streamType = "Subtitle"
rawSubtitleStreams.push(stream)
end if
end for
item.videoStreams = videoStreams
item.audioStreams = audioStreams
item.subtitleStreams = JDT_sortSubtitleStreams(rawSubtitleStreams, preferredSubtitleLanguage)
item.videoStreamCount = videoStreams.Count()
item.audioStreamCount = audioStreams.Count()
item.subtitleStreamCount = rawSubtitleStreams.Count()
end sub
' Map Chapters array to item fields.
private sub transformChapters(item as object, chapters as object)
item.chapters = chapters
item.chapterCount = chapters.Count()
end sub
' Store trickplay data for consumption by the video player.
private sub transformTrickplay(item as object, trickplayApiData as object)
item.trickplayData = trickplayApiData
end sub
' Store media attachments (embedded fonts, cover art, etc.).
private sub transformMediaAttachments(item as object, attachments as object)
item.mediaAttachments = attachments
end sub
' Pre-compute the subtitle display string shown as secondary text on cards/lists.
' @param apiData - Raw BaseItemDto assocarray
' @returns Pre-formatted subtitle string, or "" if not applicable
private function computeSubtitle(apiData as object) as string
itemType = apiData.Type ?? ""
if itemType = "Episode"
s = apiData.ParentIndexNumber
e = apiData.IndexNumber
episodeName = apiData.Name ?? ""
if not isValid(s) or not isValid(e)
return episodeName
end if
result = "S" + s.toStr() + "E" + e.toStr()
if isValidAndNotEmpty(episodeName)
result += " - " + episodeName
end if
return result
else if itemType = "Movie" or itemType = "Video" or itemType = "MusicVideo"
parts = []
year = apiData.ProductionYear
rating = apiData.OfficialRating ?? ""
if isValid(year) and year > 0
parts.push(str(year).trim())
end if
if isValidAndNotEmpty(rating)
parts.push(rating)
end if
return parts.join(" • ")
else if itemType = "Series"
year = apiData.ProductionYear
if not isValid(year) or year = 0
return ""
end if
yearStr = str(year).trim()
status = apiData.Status ?? ""
endDate = apiData.EndDate ?? ""
if status = "Ended" and isValidAndNotEmpty(endDate) and endDate.Len() >= 4
return yearStr + " - " + left(endDate, 4)
end if
return yearStr + " - Present"
else if itemType = "MusicAlbum" or itemType = "Audio"
return apiData.AlbumArtist ?? ""
else if itemType = "BoxSet" or itemType = "PhotoAlbum"
count = apiData.ChildCount ?? 0
if count = 1
return "1 item"
else if count > 1
return str(count).trim() + " items"
end if
return ""
else if itemType = "Program" or itemType = "Recording"
episodeTitle = apiData.EpisodeTitle ?? ""
if isValidAndNotEmpty(episodeTitle)
return episodeTitle
end if
return apiData.ChannelName ?? ""
else if itemType = "Season"
return apiData.SeriesName ?? ""
else if itemType = "Photo"
parts = []
year = apiData.ProductionYear
if isValid(year) and year > 0
parts.push(str(year).trim())
end if
album = apiData.Album ?? ""
if isValidAndNotEmpty(album)
parts.push(album.trim())
end if
return parts.join(" - ")
end if
return ""
end function
' Extract name strings from a NameGuidPair array (or plain string array).
private function extractNamedItemNames(items as object) as object
if not isValid(items) or items.Count() = 0
return []
end if
names = []
for each it in items
if type(it) = "roAssociativeArray" and isValid(it.Name)
names.push(it.Name)
else if type(it) = "String" or type(it) = "roString"
names.push(it)
end if
end for
return names
end function
' Extract NameGuidPair AAs from an array (filters out plain strings).
private function extractNamedItems(items as object) as object
if not isValid(items) or items.Count() = 0
return []
end if
result = []
for each it in items
if type(it) = "roAssociativeArray"
result.push(it)
end if
end for
return result
end function
' Transform a BaseItemPerson (from a parent item's People array) to a JellyfinBaseItem node.
' BaseItemPerson has {Id, Name, Type: "Actor"/"Director"/etc, Role, PrimaryImageTag}.
' Note: PrimaryImageTag is set directly (NOT nested under ImageTags like a BaseItemDto).
' Subtitle is pre-computed: "as {Role}" for actors, "{Type}" for others.
' @param person - Raw BaseItemPerson assocarray from a parent item's People array
' @returns JellyfinBaseItem node, or invalid if person is invalid
function transformPerson(person as object) as object
if not isValid(person) then return invalid
item = CreateObject("roSGNode", "JellyfinBaseItem")
item.id = person.Id ?? ""
item.name = person.Name ?? ""
item.title = item.name
item.type = "Person"
item.primaryImageTag = person.PrimaryImageTag ?? ""
' Pre-compute subtitle: "as {Role}" for actors, "{Type}" for non-actors
personType = person.Type ?? ""
role = person.Role ?? ""
if LCase(personType) = "actor" and isValidAndNotEmpty(role)
item.subtitle = "as " + role
else if isValidAndNotEmpty(personType)
item.subtitle = personType
end if
return item
end function
end class
' ── Module-level helpers ──────────────────────────────────────────────────────
' These are intentionally outside the class. BrightScript does not reliably
' propagate `m` (the class instance) into private subs invoked via method
' dispatch, so any helper called from a private sub must be a plain function.
' Normalize container format strings (m4v/mov → mp4).
function JDT_normalizeContainer(container as string) as string
if not isValidAndNotEmpty(container)
return ""
end if
c = LCase(container)
if c = "m4v" or c = "mov"
return "mp4"
end if
return c
end function
' Sort subtitle streams: forced > default > normal, preferred language first within each group.
' Ports the ordering logic from Subtitles.bs sortSubtitles() for use at transform time.
' @param streams - Array of subtitle MediaStream AAs
' @param preferredLanguage - BCP-47 language code (e.g. "eng"), or "" for no preference
' @returns Sorted subtitle stream array
function JDT_sortSubtitleStreams(streams as object, preferredLanguage as string) as object
if not isValid(streams) or streams.Count() = 0
return []
end if
forced = []
default_ = []
normal = []
for each stream in streams
lang = stream.Language ?? ""
isForced = stream.IsForced ?? false
isDefault = stream.IsDefault ?? false
isPreferred = isValidAndNotEmpty(preferredLanguage) and lang = preferredLanguage
if isForced
if isPreferred then forced.unshift(stream) else forced.push(stream)
else if isDefault
if isPreferred then default_.unshift(stream) else default_.push(stream)
else
if isPreferred then normal.unshift(stream) else normal.push(stream)
end if
end for
' Merge: forced > default > normal
default_.append(normal)
forced.append(default_)
return forced
end function