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