components_config_ServerDiscoveryTask.bs
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/utils/misc.bs"
'
' 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 = 1500
'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 isValid(msg)
ProcessSSDPResponse(msg)
end if
msg = Wait(100, m.clientDiscovery.port)
if isValid(msg)
ProcessClientDiscoveryResponse(msg)
end if
end while
FetchServerIds()
m.top.content = m.servers
m.log.debug("Jellyfin servers found", m.servers[0], m.servers[1], m.servers[2])
end sub
' Fetch server IDs and names from /system/info/public for all discovered servers.
' All requests are fired in parallel on a shared port and collected within a 5s budget.
' Also strips the URL scheme to produce originalUrl (e.g. "192.168.1.100:8096") so:
' - users see clean URLs in the server picker (no "http://" prefix)
' - inferServerUrl() can re-probe the correct protocol on each connection,
' automatically picking up HTTPS if the server has been upgraded since last save
sub FetchServerIds()
if m.servers.Count() = 0 then return
port = CreateObject("roMessagePort")
identityToIndex = {}
transfers = [] ' hold refs — roUrlTransfer is GC'd if not kept in scope
for i = 0 to m.servers.Count() - 1
serverUrl = m.servers[i].baseUrl
if not isValidAndNotEmpty(serverUrl) then continue for
http = CreateObject("roUrlTransfer")
http.SetUrl(serverUrl + "/system/info/public")
http.SetMessagePort(port)
if serverUrl.Left(8) = "https://"
http.SetCertificatesFile("common:/certs/ca-bundle.crt")
end if
http.AsyncGetToString()
identityToIndex[http.GetIdentity().ToStr()] = i
transfers.push(http)
end for
remaining = transfers.Count()
ts = CreateObject("roTimespan")
while remaining > 0 and ts.TotalMilliseconds() < 5000
resp = wait(500, port)
if type(resp) = "roUrlEvent"
idx = identityToIndex[resp.GetSourceIdentity().ToStr()]
if isValid(idx) and resp.GetResponseCode() = 200
info = ParseJson(resp.GetString())
if isValid(info)
if isValidAndNotEmpty(info.Id)
m.servers[idx].id = info.Id
end if
if isValidAndNotEmpty(info.ServerName)
m.servers[idx].name = info.ServerName
end if
end if
end if
remaining--
end if
end while
' Strip URL scheme from every discovered server to produce a clean originalUrl.
' inferServerUrl() probes http/https candidates in parallel so there is no reconnect
' penalty for servers that are HTTP-only.
for i = 0 to m.servers.Count() - 1
serverUrl = m.servers[i].baseUrl
if not isValidAndNotEmpty(serverUrl) then continue for
schemeEnd = Instr(1, serverUrl, "://")
if schemeEnd > 0
originalUrl = Mid(serverUrl, schemeEnd + 3)
else
originalUrl = serverUrl
end if
if Right(originalUrl, 1) = "/" and Len(originalUrl) > 1
originalUrl = Left(originalUrl, Len(originalUrl) - 1)
end if
m.servers[i].originalUrl = originalUrl
end for
end sub
sub AddServer(serverItem)
if not isValid(m.serverUrlMap[serverItem.baseUrl])
m.serverUrlMap[serverItem.baseUrl] = true
m.servers.push(serverItem)
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)
serverData = ParseJson(responseJson)
AddServer({
name: serverData.Name,
baseUrl: serverData.Address,
'hardcoded icon since this service doesn't include them
iconUrl: "pkg:/images/branding/logo-icon120.jpg",
iconWidth: 120,
iconHeight: 120
})
m.log.info("Found Jellyfin server using client discovery", serverData.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 not isValid(locationUrl)
return
else if isValid(m.locationUrlMap[locationUrl])
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
serverData = invalid
icons = deviceNode.GetNamedElementsCi("iconList")[0].GetNamedElementsCi("icon")
dlnaRegex = CreateObject("roRegex", "(.*?)\/dlna\/", "i")
for each iconNode in icons
iconUrl = iconNode.GetNamedElementsCi("url").GetText()
baseUrl = invalid
match = dlnaRegex.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 isValid(baseUrl) and loopResult.iconWidth > width
width = loopResult.iconWidth
serverData = loopResult
end if
end for
AddServer(serverData)
m.log.info("Found jellyfin server using SSDP and DLNA", serverData.baseUrl)
end if
end if
end sub