components_config_ServerDiscoveryTask.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"

'
' Task used to discover jellyfin servers on the local network
'
sub init()
    m.log = log.Logger("ServerDiscoveryTask")
    m.top.functionName = "execute"
end sub

sub execute()
    m.servers = []
    m.serverUrlMap = {}
    m.locationUrlMap = {}
    'send both requests at the same time
    SendSSDPBroadcast()
    SendClientDiscoveryBroadcast()

    ts = CreateObject("roTimespan")
    maxTimeMs = 2200

    'monitor each port and collect messages
    while True
        elapsed = ts.TotalMilliseconds()
        if elapsed >= maxTimeMs
            exit while
        end if

        msg = Wait(100, m.ssdp.port)
        if msg <> invalid
            ProcessSSDPResponse(msg)
        end if

        msg = Wait(100, m.clientDiscovery.port)
        if msg <> invalid
            ProcessClientDiscoveryResponse(msg)
        end if

    end while

    m.top.content = m.servers
    m.log.debug("Jellyfin servers found", m.servers[0], m.servers[1], m.servers[2])
end sub

sub AddServer(server)
    if m.serverUrlMap[server.baseUrl] = invalid
        m.serverUrlMap[server.baseUrl] = true
        m.servers.push(server)
    end if
end sub

sub SendClientDiscoveryBroadcast()
    m.clientDiscovery = {
        port: CreateObject("roMessagePort"),
        address: CreateObject("roSocketAddress"),
        socket: CreateObject("roDatagramSocket"),
        urlTransfer: CreateObject("roUrlTransfer")
    }
    m.clientDiscovery.address.SetAddress("255.255.255.255:7359")
    m.clientDiscovery.urlTransfer.SetPort(m.clientDiscoveryPort)
    m.clientDiscovery.socket.SetMessagePort(m.clientDiscovery.port)
    m.clientDiscovery.socket.SetSendToAddress(m.clientDiscovery.address)
    m.clientDiscovery.socket.NotifyReadable(true)
    m.clientDiscovery.socket.SetBroadcast(true)
    m.clientDiscovery.socket.SendStr("Who is JellyfinServer?")
end sub

sub ProcessClientDiscoveryResponse(message)
    if Type(message) = "roSocketEvent" and message.GetSocketId() = m.clientDiscovery.socket.GetId() and m.clientDiscovery.socket.IsReadable()
        try
            responseJson = m.clientDiscovery.socket.ReceiveStr(4096)
            server = ParseJson(responseJson)
            AddServer({
                name: server.Name,
                baseUrl: server.Address,
                'hardcoded icon since this service doesn't include them
                iconUrl: "pkg:/images/logo-icon120.jpg",
                iconWidth: 120,
                iconHeight: 120
            })
            m.log.info("Found Jellyfin server using client discovery", server.Address)
        catch e
            m.log.error("Error scanning for jellyfin server", message)
        end try
    end if
end sub

sub SendSSDPBroadcast()
    m.ssdp = {
        port: CreateObject("roMessagePort"),
        address: CreateObject("roSocketAddress"),
        socket: CreateObject("roDatagramSocket"),
        urlTransfer: CreateObject("roUrlTransfer")
    }
    m.ssdp.address.SetAddress("239.255.255.250:1900")
    m.ssdp.socket.SetMessagePort(m.ssdp.port)
    m.ssdp.socket.SetSendToAddress(m.ssdp.address)
    m.ssdp.socket.NotifyReadable(true)
    m.ssdp.urlTransfer.SetPort(m.ssdp.port)

    'brightscript can't escape characters in strings, so create a few vars here so we can use them in the strings below
    Q = Chr(34)
    CRLF = Chr(13) + Chr(10)

    ssdpStr = "M-SEARCH * HTTP/1.1" + CRLF
    ssdpStr += "HOST: 239.255.255.250:1900" + CRLF
    ssdpStr += "MAN: " + Q + "ssdp:discover" + Q + CRLF
    ssdpStr += "ST:urn:schemas-upnp-org:device:MediaServer:1" + CRLF
    ssdpStr += "MX: 2" + CRLF
    ssdpStr += CRLF

    m.ssdp.socket.SendStr(ssdpStr)
end sub

sub ProcessSSDPResponse(message)
    locationUrl = invalid
    if Type (message) = "roSocketEvent" and message.GetSocketId() = m.ssdp.socket.GetId() and m.ssdp.socket.IsReadable()
        recvStr = m.ssdp.socket.ReceiveStr(4096)
        match = CreateObject("roRegex", "\r\nLocation:\s*(.*?)\s*\r\n", "i").Match(recvStr)
        if match.Count() = 2
            locationUrl = match[1]
        end if
    end if

    if locationUrl = invalid
        return
    else if m.locationUrlMap[locationUrl] <> invalid
        m.log.warn("Already discovered this location", locationUrl)
        return
    end if

    m.locationUrlMap[locationUrl] = true

    http = CreateObject("roUrlTransfer")
    http.SetUrl(locationUrl)
    responseText = http.GetToString()
    xml = CreateObject("roXMLElement")
    'if we successfully parsed the response, process it
    if xml.Parse(responseText)
        deviceNode = xml.GetNamedElementsCi("device")[0]
        manufacturer = deviceNode.GetNamedElementsCi("manufacturer").GetText()
        'only process jellyfin servers
        if lcase(manufacturer) = "jellyfin"
            'find the largest icon
            width = 0
            server = invalid
            icons = deviceNode.GetNamedElementsCi("iconList")[0].GetNamedElementsCi("icon")
            for each iconNode in icons
                iconUrl = iconNode.GetNamedElementsCi("url").GetText()
                baseUrl = invalid
                match = CreateObject("roRegex", "(.*?)\/dlna\/", "i").Match(iconUrl)
                if match.Count() = 2
                    baseUrl = match[1]
                end if
                loopResult = {
                    name: deviceNode.GetNamedElementsCi("friendlyName").GetText(),
                    baseUrl: baseUrl,
                    iconUrl: iconUrl,
                    iconWidth: iconNode.GetNamedElementsCi("width")[0].GetText().ToInt(),
                    iconHeight: iconNode.GetNamedElementsCi("height")[0].GetText().ToInt()
                }
                if baseUrl <> invalid and loopResult.iconWidth > width
                    width = loopResult.iconWidth
                    server = loopResult
                end if
            end for
            AddServer(server)
            m.log.info("Found jellyfin server using SSDP and DLNA", server.baseUrl)
        end if
    end if
end sub