source_utils_subtitles.bs
import "pkg:/source/enums/SubtitleSelection.bs"
' defaultSubtitleTrackFromVid: Identifies the default subtitle track given metadata and audio index
'
' @param {object} meta - metadata object containing MediaSources with MediaStreams
' @param {integer} selectedAudioIndex - index of selected audio stream (used for Smart mode language matching)
' @param {dynamic} [mediaSourceId=invalid] - optional MediaSource ID to use instead of mediaSources[0]
' @return {integer} subtitle track index or SubtitleSelection.NONE if not found
function defaultSubtitleTrackFromVid(meta as object, selectedAudioIndex as integer, mediaSourceId = invalid as dynamic) as integer
userSession = m.global.user
if userSession.config.subtitleMode = "None"
return SubtitleSelection.NONE ' No subtitles desired: return none
end if
if not isValid(meta) then return SubtitleSelection.NONE
mediaSources = invalid
if isValid(meta.mediaSourcesData) and isValidAndNotEmpty(meta.mediaSourcesData.mediaSources)
mediaSources = meta.mediaSourcesData.mediaSources
end if
if not isValid(mediaSources) then return SubtitleSelection.NONE
' Use the selected source's MediaStreams when a specific mediaSourceId is provided,
' matching the pattern used for audio stream selection in LoadVideoContentTask.
allStreams = invalid
if isValidAndNotEmpty(mediaSourceId)
for each source in mediaSources
if source.Id = mediaSourceId and isValidAndNotEmpty(source.MediaStreams)
allStreams = source.MediaStreams
exit for
end if
end for
end if
' Fall back to first source if no match or no mediaSourceId provided
if not isValid(allStreams)
if not isValidAndNotEmpty(mediaSources[0].MediaStreams) then return SubtitleSelection.NONE
allStreams = mediaSources[0].MediaStreams
end if
subtitles = sortSubtitles(allStreams)
selectedAudioLanguage = ""
' Find the audio stream with the matching Jellyfin index
audioMediaStream = invalid
for each stream in allStreams
if isValid(stream.index) and stream.index = selectedAudioIndex
audioMediaStream = stream
exit for
end if
end for
' Ensure audio media stream is valid before using language property
if isValid(audioMediaStream)
selectedAudioLanguage = audioMediaStream.Language ?? ""
end if
defaultTextSubs = defaultSubtitleTrack(subtitles["text"], selectedAudioLanguage, true) ' Find correct subtitle track (forced text)
if defaultTextSubs <> SubtitleSelection.NONE
return defaultTextSubs
end if
if not userSession.settings.playbackSubsOnlyText
return defaultSubtitleTrack(subtitles["all"], selectedAudioLanguage) ' if no appropriate text subs exist, allow non-text
end if
return SubtitleSelection.NONE
end function
' defaultSubtitleTrack:
'
' @param {dynamic} sortedSubtitles - array of subtitles sorted by type and language
' @param {string} selectedAudioLanguage - language for selected audio track
' @param {boolean} [requireText=false] - indicates if only text subtitles should be considered
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.NONE} if one is not found
function defaultSubtitleTrack(sortedSubtitles, selectedAudioLanguage as string, requireText = false as boolean) as integer
' ONE rendezvous to get user node
localUser = m.global.user
subtitleMode = isValid(localUser.config.subtitleMode) ? LCase(localUser.config.subtitleMode) : ""
prefLang = resolveSubtitleLanguagePreference(localUser.settings, localUser.config)
allowSmartMode = false
' Only evaluate selected audio language if we have a value
if selectedAudioLanguage <> ""
allowSmartMode = selectedAudioLanguage <> prefLang
end if
for each item in sortedSubtitles
' Only auto-select subtitle if language matches SubtitleLanguagePreference
languageMatch = true
if prefLang <> ""
languageMatch = (prefLang = item.Track.Language)
end if
' Ensure textuality of subtitle matches preference passed as arg
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
if languageMatch and matchTextReq
if subtitleMode = "default" and (item.IsForced or item.IsDefault)
' Return first forced or default subtitle track
return item.Index
else if subtitleMode = "always" and not item.IsForced
' Return the first non-forced subtitle track (full subs preferred over forced)
return item.Index
else if subtitleMode = "onlyforced" and item.IsForced
' Return first forced subtitle track
return item.Index
else if subtitleMode = "smart" and allowSmartMode and not item.IsForced
' Return the first non-forced subtitle track (full subs when audio differs from preference)
return item.Index
end if
end if
end for
' Always mode fallback: if no full (non-forced) subtitles found, fall back to forced/default
if subtitleMode = "always"
for each item in sortedSubtitles
' Ensure textuality of subtitle matches preference passed as arg
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
if matchTextReq
if item.IsForced or item.IsDefault
' Return first forced or default subtitle track as fallback
return item.Index
end if
end if
end for
end if
' User has chosen smart subtitle mode
' We already attempted to load subtitles in preferred language, but none were found.
' Fall back to default behaviour while ignoring preferredlanguage
if subtitleMode = "smart" and allowSmartMode
for each item in sortedSubtitles
' Ensure textuality of subtitle matches preference passed as arg
matchTextReq = ((requireText and item.IsTextSubtitleStream) or not requireText)
if matchTextReq
if item.IsForced or item.IsDefault
' Return first forced or default subtitle track
return item.Index
end if
end if
end for
end if
return SubtitleSelection.NONE ' Keep current default behavior of "None", if no correct subtitle is identified
end function
'Checks available subtitle tracks and puts subtitles in forced, default, and non-default/forced but preferred language at the top
function sortSubtitles(MediaStreams)
tracks = { "forced": [], "default": [], "normal": [], "textForced": [], "textDefault": [], "textNormal": [] }
' ONE rendezvous to get user node
localUser = m.global.user
preferredLang = resolveSubtitleLanguagePreference(localUser.settings, localUser.config)
for each stream in MediaStreams
if stream.type = "Subtitle"
url = ""
if isValid(stream.DeliveryUrl)
builtUrl = buildURL(stream.DeliveryUrl)
if isValid(builtUrl)
url = builtUrl as string
end if
end if
stream = {
"Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
"IsTextSubtitleStream": stream.IsTextSubtitleStream ?? false,
"Index": stream.index,
"IsDefault": stream.IsDefault ?? false,
"IsForced": stream.IsForced ?? false,
"IsExternal": stream.IsExternal ?? false,
"IsEncoded": stream.DeliveryMethod = "Encode"
}
if stream.IsForced = true
trackType = "forced"
textType = "textForced"
else if stream.IsDefault = true
trackType = "default"
textType = "textDefault"
else
trackType = "normal"
textType = "textNormal"
end if
if preferredLang <> "" and preferredLang = stream.Track.Language
tracks[trackType].unshift(stream)
if stream.IsTextSubtitleStream
tracks[textType].unshift(stream)
end if
else
tracks[trackType].push(stream)
if stream.IsTextSubtitleStream
tracks[textType].push(stream)
end if
end if
end if
end for
tracks["default"].append(tracks["normal"])
tracks["forced"].append(tracks["default"])
' Merge text buckets with same priority ordering: forced > default > normal
tracks["textDefault"].append(tracks["textNormal"])
tracks["textForced"].append(tracks["textDefault"])
return { "all": tracks["forced"], "text": tracks["textForced"] }
end function