components_ItemGrid_LoadVideoContentTask.bs

import "pkg:/source/utils/misc.bs"
import "pkg:/source/api/Items.bs"
import "pkg:/source/api/UserLibrary.bs"
import "pkg:/source/api/baserequest.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/api/Image.bs"
import "pkg:/source/api/userauth.bs"
import "pkg:/source/utils/deviceCapabilities.bs"

enum SubtitleSelection
    notset = -2
    none = -1
end enum

sub init()
    m.user = AboutMe()
    m.top.functionName = "loadItems"
end sub

sub loadItems()
    ' Reset intro tracker in case task gets reused
    m.top.isIntro = false

    ' Only show preroll once per queue
    if m.global.queueManager.callFunc("isPrerollActive")
        ' Prerolls not allowed if we're resuming video
        if m.global.queueManager.callFunc("getCurrentItem").startingPoint = 0
            preRoll = GetIntroVideos(m.top.itemId)
            if isValid(preRoll) and preRoll.TotalRecordCount > 0 and isValid(preRoll.items[0])
                ' If an error is thrown in the Intros plugin, instead of passing the error they pass the entire rick roll music video.
                ' Bypass the music video and treat it as an error message
                if lcase(preRoll.items[0].name) <> "rick roll'd"
                    m.global.queueManager.callFunc("push", m.global.queueManager.callFunc("getCurrentItem"))
                    m.top.itemId = preRoll.items[0].id
                    m.global.queueManager.callFunc("setPrerollStatus", false)
                    m.top.isIntro = true
                end if
            end if
        end if
    end if

    if m.top.selectedAudioStreamIndex = 0
        currentItem = m.global.queueManager.callFunc("getCurrentItem")
        if isValid(currentItem) and isValid(currentItem.json)
            m.top.selectedAudioStreamIndex = FindPreferredAudioStream(currentItem.json.MediaStreams)
        end if
    end if

    id = m.top.itemId
    mediaSourceId = invalid
    audio_stream_idx = m.top.selectedAudioStreamIndex
    forceTranscoding = false

    m.top.content = [LoadItems_VideoPlayer(id, mediaSourceId, audio_stream_idx, forceTranscoding)]
end sub

function LoadItems_VideoPlayer(id as string, mediaSourceId = invalid as dynamic, audio_stream_idx = 1 as integer, forceTranscoding = false as boolean) as dynamic

    video = {}
    video.id = id
    video.content = createObject("RoSGNode", "ContentNode")

    LoadItems_AddVideoContent(video, mediaSourceId, audio_stream_idx, forceTranscoding)

    if video.content = invalid
        return invalid
    end if

    return video
end function

sub LoadItems_AddVideoContent(video as object, mediaSourceId as dynamic, audio_stream_idx = 1 as integer, forceTranscoding = false as boolean)

    meta = ItemMetaData(video.id)
    subtitle_idx = m.top.selectedSubtitleIndex

    if not isValid(meta)
        video.errorMsg = "Error loading metadata"
        video.content = invalid
        return
    end if

    videotype = LCase(meta.type)

    ' Check for any Live TV streams or Recordings coming from other places other than the TV Guide
    if videotype = "recording" or (isValid(meta.json) and isValid(meta.json.ChannelId))
        if isValid(meta.json.EpisodeTitle)
            meta.title = meta.json.EpisodeTitle
        else if isValid(meta.json.Name)
            meta.title = meta.json.Name
        end if
        meta.live = true
        if LCase(meta.json.type) = "program"
            video.id = meta.json.ChannelId
        else
            video.id = meta.json.id
        end if
    end if

    if videotype = "episode" or videotype = "series"
        video.content.contenttype = "episode"
    end if

    video.chapters = meta.json.Chapters
    video.content.title = meta.title
    video.showID = meta.showID

    user = AboutMe()
    if user.Configuration.EnableNextEpisodeAutoPlay
        if LCase(m.top.itemType) = "episode"
            addNextEpisodesToQueue(video.showID)
        end if
    end if

    playbackPosition = 0!

    currentItem = m.global.queueManager.callFunc("getCurrentItem")

    if isValid(currentItem) and isValid(currentItem.startingPoint)
        playbackPosition = currentItem.startingPoint
    end if

    ' PlayStart requires the time to be in seconds
    video.content.PlayStart = int(playbackPosition / 10000000)

    if not isValid(mediaSourceId) then mediaSourceId = video.id
    if meta.live then mediaSourceId = ""

    m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition)
    if not isValid(m.playbackInfo)
        video.errorMsg = "Error loading playback info"
        video.content = invalid
        return
    end if

    addSubtitlesToVideo(video, meta)

    ' Enable default subtitle track
    if subtitle_idx = SubtitleSelection.notset
        defaultSubtitleIndex = defaultSubtitleTrackFromVid(video.id)

        if defaultSubtitleIndex <> SubtitleSelection.none
            video.SelectedSubtitle = defaultSubtitleIndex
            subtitle_idx = defaultSubtitleIndex

            m.playbackInfo = ItemPostPlaybackInfo(video.id, mediaSourceId, audio_stream_idx, subtitle_idx, playbackPosition)
            if not isValid(m.playbackInfo)
                video.errorMsg = "Error loading playback info"
                video.content = invalid
                return
            end if

            addSubtitlesToVideo(video, meta)
        else
            video.SelectedSubtitle = subtitle_idx
        end if
    else
        video.SelectedSubtitle = subtitle_idx
    end if

    video.videoId = video.id
    video.mediaSourceId = mediaSourceId
    video.audioIndex = audio_stream_idx

    video.PlaySessionId = m.playbackInfo.PlaySessionId

    if meta.live
        video.content.live = true
        video.content.StreamFormat = "hls"
    end if

    video.container = getContainerType(meta)

    if not isValid(m.playbackInfo.MediaSources[0])
        m.playbackInfo = meta.json
    end if

    addAudioStreamsToVideo(video)
    if meta.live
        video.transcodeParams = {
            "MediaSourceId": m.playbackInfo.MediaSources[0].Id,
            "LiveStreamId": m.playbackInfo.MediaSources[0].LiveStreamId,
            "PlaySessionId": video.PlaySessionId
        }
    end if


    ' 'TODO: allow user selection of subtitle track before playback initiated, for now set to no subtitles
    video.directPlaySupported = m.playbackInfo.MediaSources[0].SupportsDirectPlay
    fully_external = false


    ' For h264/hevc video, Roku spec states that it supports specfic encoding levels
    ' The device can decode content with a Higher Encoding level but may play it back with certain
    ' artifacts. If the user preference is set, and the only reason the server says we need to
    ' transcode is that the Encoding Level is not supported, then try to direct play but silently
    ' fall back to the transcode if that fails.
    if m.playbackInfo.MediaSources[0].MediaStreams.Count() > 0 and meta.live = false
        tryDirectPlay = m.global.session.user.settings["playback.tryDirect.h264ProfileLevel"] and m.playbackInfo.MediaSources[0].MediaStreams[0].codec = "h264"
        tryDirectPlay = tryDirectPlay or (m.global.session.user.settings["playback.tryDirect.hevcProfileLevel"] and m.playbackInfo.MediaSources[0].MediaStreams[0].codec = "hevc")
        if tryDirectPlay and isValid(m.playbackInfo.MediaSources[0].TranscodingUrl) and forceTranscoding = false
            transcodingReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
            if transcodingReasons.Count() = 1 and transcodingReasons[0] = "VideoLevelNotSupported"
                video.directPlaySupported = true
                video.transcodeAvailable = true
            end if
        end if
    end if

    if video.directPlaySupported
        video.isTranscoded = false
        addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
    else
        if m.playbackInfo.MediaSources[0].TranscodingUrl = invalid
            ' If server does not provide a transcode URL, display a message to the user
            m.global.sceneManager.callFunc("userMessage", tr("Error Getting Playback Information"), tr("An error was encountered while playing this item.  Server did not provide required transcoding data."))
            video.errorMsg = "Error getting playback information"
            video.content = invalid
            return
        end if
        ' Get transcoding reason
        video.transcodeReasons = getTranscodeReasons(m.playbackInfo.MediaSources[0].TranscodingUrl)
        video.content.url = buildURL(m.playbackInfo.MediaSources[0].TranscodingUrl)
        video.isTranscoded = true
    end if

    setCertificateAuthority(video.content)
    video.audioTrack = (audio_stream_idx + 1).ToStr() ' Roku's track indexes count from 1. Our index is zero based

    if not fully_external
        video.content = authRequest(video.content)
    end if
end sub

' defaultSubtitleTrackFromVid: Identifies the default subtitle track given video id
'
' @param {dynamic} videoID - id of video user is playing
' @return {integer} indicating the default track's server-side index. Defaults to {SubtitleSelection.none} is one is not found
function defaultSubtitleTrackFromVid(videoID) as integer
    if m.global.session.user.configuration.SubtitleMode = "None"
        return SubtitleSelection.none ' No subtitles desired: return none
    end if

    meta = ItemMetaData(videoID)

    if not isValid(meta) then return SubtitleSelection.none
    if not isValid(meta.json) then return SubtitleSelection.none
    if not isValidAndNotEmpty(meta.json.mediaSources) then return SubtitleSelection.none
    if not isValidAndNotEmpty(meta.json.MediaSources[0].MediaStreams) then return SubtitleSelection.none

    subtitles = sortSubtitles(meta.id, meta.json.MediaSources[0].MediaStreams)

    selectedAudioLanguage = ""
    audioMediaStream = meta.json.MediaSources[0].MediaStreams[m.top.selectedAudioStreamIndex]

    ' 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 m.global.session.user.settings["playback.subs.onlytext"]
        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} is one is not found
function defaultSubtitleTrack(sortedSubtitles, selectedAudioLanguage as string, requireText = false as boolean) as integer
    userConfig = m.global.session.user.configuration

    subtitleMode = isValid(userConfig.SubtitleMode) ? LCase(userConfig.SubtitleMode) : ""

    allowSmartMode = false

    ' Only evaluate selected audio language if we have a value
    if selectedAudioLanguage <> ""
        allowSmartMode = selectedAudioLanguage <> userConfig.SubtitleLanguagePreference
    end if

    for each item in sortedSubtitles
        ' Only auto-select subtitle if language matches SubtitleLanguagePreference
        languageMatch = true
        if userConfig.SubtitleLanguagePreference <> ""
            languageMatch = (userConfig.SubtitleLanguagePreference = item.Track.Language)
        end if

        ' Ensure textuality of subtitle matches preferenced 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"
                ' Return the first found subtitle track
                return item.Index
            else if subtitleMode = "onlyforced" and item.IsForced
                ' Return first forced subtitle track
                return item.Index
            else if subtitleMode = "smart" and allowSmartMode
                ' Return the first found subtitle track
                return item.Index
            end if
        end if
    end for

    ' User has chosed 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 preferenced 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

sub addVideoContentURL(video, mediaSourceId, audio_stream_idx, fully_external)
    protocol = LCase(m.playbackInfo.MediaSources[0].Protocol)
    if protocol <> "file"
        uri = parseUrl(m.playbackInfo.MediaSources[0].Path)
        if isLocalhost(uri[2])
            ' if the domain of the URI is local to the server,
            ' create a new URI by appending the received path to the server URL
            ' later we will substitute the users provided URL for this case
            video.content.url = buildURL(uri[4])
        else
            fully_external = true
            video.content.url = m.playbackInfo.MediaSources[0].Path
        end if
    else
        params = {
            "Static": "true",
            "Container": video.container,
            "PlaySessionId": video.PlaySessionId,
            "AudioStreamIndex": audio_stream_idx
        }

        if mediaSourceId <> ""
            params.MediaSourceId = mediaSourceId
        end if

        video.content.url = buildURL(Substitute("Videos/{0}/stream", video.id), params)
    end if
end sub


' addAudioStreamsToVideo: Add audio stream data to video
'
' @param {dynamic} video component to add fullAudioData to
sub addAudioStreamsToVideo(video)
    audioStreams = []
    mediaStreams = m.playbackInfo.MediaSources[0].MediaStreams

    for i = 0 to mediaStreams.Count() - 1
        if LCase(mediaStreams[i].Type) = "audio"
            audioStreams.push(mediaStreams[i])
        end if
    end for

    video.fullAudioData = audioStreams
end sub

sub addSubtitlesToVideo(video, meta)
    subtitles = sortSubtitles(meta.id, m.playbackInfo.MediaSources[0].MediaStreams)
    safesubs = subtitles["all"]
    subtitleTracks = []

    if m.global.session.user.settings["playback.subs.onlytext"] = true
        safesubs = subtitles["text"]
    end if

    for each subtitle in safesubs
        subtitleTracks.push(subtitle.track)
    end for

    video.content.SubtitleTracks = subtitleTracks
    video.fullSubtitleData = safesubs
end sub


'
' Extract array of Transcode Reasons from the content URL
' @returns Array of Strings
function getTranscodeReasons(url as string) as object

    regex = CreateObject("roRegex", "&TranscodeReasons=([^&]*)", "")
    match = regex.Match(url)

    if match.count() > 1
        return match[1].Split(",")
    end if

    return []
end function

function directPlaySupported(meta as object) as boolean
    devinfo = CreateObject("roDeviceInfo")
    if isValid(meta.json.MediaSources[0]) and meta.json.MediaSources[0].SupportsDirectPlay = false
        return false
    end if

    if meta.json.MediaStreams[0] = invalid
        return false
    end if

    streamInfo = { Codec: meta.json.MediaStreams[0].codec }
    if isValid(meta.json.MediaStreams[0].Profile) and meta.json.MediaStreams[0].Profile.len() > 0
        streamInfo.Profile = LCase(meta.json.MediaStreams[0].Profile)
    end if
    if isValid(meta.json.MediaSources[0].container) and meta.json.MediaSources[0].container.len() > 0
        'CanDecodeVideo() requires the .container to be format: “mp4”, “hls”, “mkv”, “ism”, “dash”, “ts” if its to direct stream
        if meta.json.MediaSources[0].container = "mov"
            streamInfo.Container = "mp4"
        else
            streamInfo.Container = meta.json.MediaSources[0].container
        end if
    end if

    decodeResult = devinfo.CanDecodeVideo(streamInfo)
    return decodeResult <> invalid and decodeResult.result

end function

function getContainerType(meta as object) as string
    ' Determine the file type of the video file source
    if meta.json.mediaSources = invalid then return ""

    container = meta.json.mediaSources[0].container
    if container = invalid
        container = ""
    else if container = "m4v" or container = "mov"
        container = "mp4"
    end if

    return container
end function

' Add next episodes to the playback queue
sub addNextEpisodesToQueue(showID)
    ' Don't queue next episodes if we already have a playback queue
    maxQueueCount = 1

    if m.top.isIntro
        maxQueueCount = 2
    end if

    if m.global.queueManager.callFunc("getCount") > maxQueueCount then return

    videoID = m.top.itemId

    ' If first item is an intro video, use the next item in the queue
    if m.top.isIntro
        currentVideo = m.global.queueManager.callFunc("getItemByIndex", 1)

        if isValid(currentVideo) and isValid(currentVideo.id)
            videoID = currentVideo.id

            ' Override showID value since it's for the intro video
            meta = ItemMetaData(videoID)
            if isValid(meta)
                showID = meta.showID
            end if
        end if
    end if

    url = Substitute("Shows/{0}/Episodes", showID)
    urlParams = { "UserId": m.global.session.user.id }
    urlParams.Append({ "StartItemId": videoID })
    urlParams.Append({ "Limit": 50 })
    resp = APIRequest(url, urlParams)
    data = getJson(resp)

    if isValid(data) and data.Items.Count() > 1
        for i = 1 to data.Items.Count() - 1
            m.global.queueManager.callFunc("push", data.Items[i])
        end for
    end if
end sub

'Checks available subtitle tracks and puts subtitles in forced, default, and non-default/forced but preferred language at the top
function sortSubtitles(id as string, MediaStreams)
    tracks = { "forced": [], "default": [], "normal": [], "text": [] }
    'Too many args for using substitute
    prefered_lang = m.global.session.user.configuration.SubtitleLanguagePreference
    for each stream in MediaStreams
        if stream.type = "Subtitle"

            url = ""
            if isValid(stream.DeliveryUrl)
                url = buildURL(stream.DeliveryUrl)
            end if

            stream = {
                "Track": { "Language": stream.language, "Description": stream.displaytitle, "TrackName": url },
                "IsTextSubtitleStream": stream.IsTextSubtitleStream,
                "Index": stream.index,
                "IsDefault": stream.IsDefault,
                "IsForced": stream.IsForced,
                "IsExternal": stream.IsExternal,
                "IsEncoded": stream.DeliveryMethod = "Encode"
            }

            if stream.isForced
                trackType = "forced"
            else if stream.IsDefault
                trackType = "default"
            else
                trackType = "normal"
            end if

            if prefered_lang <> "" and prefered_lang = stream.Track.Language
                tracks[trackType].unshift(stream)

                if stream.IsTextSubtitleStream
                    tracks["text"].unshift(stream)
                end if
            else
                tracks[trackType].push(stream)

                if stream.IsTextSubtitleStream
                    tracks["text"].push(stream)
                end if
            end if
        end if
    end for

    tracks["default"].append(tracks["normal"])
    tracks["forced"].append(tracks["default"])

    return { "all": tracks["forced"], "text": tracks["text"] }
end function

function FindPreferredAudioStream(streams as dynamic) as integer
    preferredLanguage = m.user.Configuration.AudioLanguagePreference
    playDefault = m.user.Configuration.PlayDefaultAudioTrack

    if playDefault <> invalid and playDefault = true
        return 1
    end if

    ' Do we already have the MediaStreams or not?
    if streams = invalid
        url = Substitute("Users/{0}/Items/{1}", m.user.id, m.top.itemId)
        resp = APIRequest(url)
        jsonResponse = getJson(resp)

        if jsonResponse = invalid or jsonResponse.MediaStreams = invalid then return 1

        streams = jsonResponse.MediaStreams
    end if

    if preferredLanguage <> invalid
        for i = 0 to streams.Count() - 1
            if LCase(streams[i].Type) = "audio"
                if streams[i].Language <> invalid and LCase(streams[i].Language) = LCase(preferredLanguage)
                    return i
                end if
            end if
        end for
    end if

    return 1
end function