Merge pull request #134 from nielsvanvelzen/recommended-servers

Add recommended server discovery
This commit is contained in:
Bill Thornton 2020-10-31 00:52:13 -04:00 committed by GitHub
commit d5ec4e8e9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 342 additions and 21 deletions

View File

@ -20,7 +20,7 @@ object Dependencies {
private fun item(module: String, version: String) = "org.jetbrains.kotlinx:kotlinx-${module}:${version}"
val cli = item("cli", "0.2.1")
val coroutinesCore = item("coroutines-core", "1.3.9")
val coroutinesCore = item("coroutines-core", "1.4.0")
val serializationJson = item("serialization-json", "1.0.0")
}

View File

@ -97,7 +97,7 @@ class WebSocketApi(
.catch { logger.error(it) }
.onCompletion {
// Reconnect
logger.debug("Socket receiver completed, found %s subscriptions", subscriptions.size)
logger.debug("Socket receiver completed, found ${subscriptions.size} subscriptions")
delay(RECONNECT_DELAY)
if (subscriptions.isNotEmpty()) reconnect()
}
@ -113,7 +113,7 @@ class WebSocketApi(
.collect()
private suspend fun subscriptionsChanged() {
logger.debug("Subscriptions changed to %s", subscriptions.size)
logger.debug("Subscriptions changed to ${subscriptions.size}")
if (socketJob != null && subscriptions.isEmpty()) {
logger.info("Dropping connection")

View File

@ -1,6 +0,0 @@
package org.jellyfin.apiclient
data class AppInfo(
val name: String,
val version: String
)

View File

@ -4,6 +4,7 @@ import org.jellyfin.apiclient.api.client.KtorClient
import org.jellyfin.apiclient.discovery.DiscoveryService
import org.jellyfin.apiclient.model.ClientInfo
import org.jellyfin.apiclient.model.DeviceInfo
import org.jellyfin.apiclient.model.discovery.ServerVersion
class Jellyfin(
private val options: JellyfinOptions
@ -11,7 +12,7 @@ class Jellyfin(
constructor(initOptions: JellyfinOptions.Builder.() -> Unit) : this(JellyfinOptions.build(initOptions))
val discovery by lazy {
DiscoveryService(options.discoverBroadcastAddressesProvider)
DiscoveryService(this, options.discoverBroadcastAddressesProvider)
}
/**
@ -35,4 +36,9 @@ class Jellyfin(
deviceInfo
)
}
companion object {
val recommendedVersion = ServerVersion(10, 7, 0)
val apiVersion = ServerVersion(10, 7, 0)
}
}

View File

@ -32,7 +32,7 @@ class AddressCandidateHelper(
init {
try {
logger.debug("Input is %s", input)
logger.debug("Input is $input")
// Add the input as initial candidate
candidates.add(URLBuilder().apply {
@ -45,7 +45,7 @@ class AddressCandidateHelper(
}.build())
} catch (error: URLParserException) {
// Input can't be parsed
logger.error("Input %s could not be parsed", input, error)
logger.error("Input $input could not be parsed", error)
}
}
@ -152,7 +152,7 @@ class AddressCandidateHelper(
* Get all deduplicated candidate URLs as strings.
* Call [prioritize] before if a sorted list is desired.
*/
fun getCandidates(): Collection<String> = candidates
fun getCandidates(): List<String> = candidates
.map { it.toString() }
.distinct()
}

View File

@ -1,16 +1,23 @@
package org.jellyfin.apiclient.discovery
import kotlinx.coroutines.flow.takeWhile
import org.jellyfin.apiclient.Jellyfin
/**
* Service for discovery related functionality
*/
class DiscoveryService(
private val jellyfin: Jellyfin,
private val discoveryBroadcastAddressesProvider: DiscoveryBroadcastAddressesProvider
) {
private val localServerDiscovery by lazy {
// Create instance
LocalServerDiscovery(discoveryBroadcastAddressesProvider)
}
private val recommendedServerDiscovery by lazy {
RecommendedServerDiscovery(jellyfin)
}
/**
* Parses the given [input] and tries to fix common mistakes.
*
@ -27,6 +34,68 @@ class DiscoveryService(
getCandidates()
}
/**
* Connects to the [servers] and returns a list of responding servers ordered by input order.
* Uses multiple rules to determine the recommendation like:
*
* - HTTPS above HTTP
* - Higher Jellyfin version
* - Respond time
*
* Also adds the addresses reported by the server into the mix when visiting the public server
* information. Reported addresses are inserted before their parent server.
*
* If the input in [servers] is the output from [getAddressCandidates] the optimal server to use
* will be the first returned server with a [RecommendedServerInfoScore] of GOOD.
*
* Optionally use [getRecommendedServer] to make this selection automatically.
*/
suspend fun getRecommendedServers(
servers: List<String>,
includeAppendedServers: Boolean = true,
minimumScore: RecommendedServerInfoScore = RecommendedServerInfoScore.BAD
) = recommendedServerDiscovery.discover(
servers = servers,
includeAppendedServers = includeAppendedServers,
minimumScore = minimumScore
)
/**
* Utility function that calls [getRecommendedServers] with the output of [getAddressCandidates].
*/
suspend fun getRecommendedServers(
input: String,
includeAppendedServers: Boolean = true,
minimumScore: RecommendedServerInfoScore = RecommendedServerInfoScore.BAD
) = getRecommendedServers(
servers = getAddressCandidates(input),
includeAppendedServers = includeAppendedServers,
minimumScore = minimumScore
)
/**
* Utility function that calls [getRecommendedServers] for inputted [servers] and returns the
* best candidate. Returns when a server with a score of [RecommendedServerInfoScore.GOOD] is
* found or otherwise collects all servers and returns the best one based on order and score.
*/
suspend fun getRecommendedServer(
servers: List<String>,
includeAppendedServers: Boolean = true
): RecommendedServerInfo? {
var best: RecommendedServerInfo? = null
getRecommendedServers(servers, includeAppendedServers).takeWhile {
// Select if it's better than current
if (best == null || it.score.score > best!!.score.score)
best = it
// Take while score is not GOOD (highest possible value)
best!!.score != RecommendedServerInfoScore.GOOD
}
return best
}
/**
* Discover servers on the local network. Uses the [discoveryBroadcastAddressesProvider] to
* find local devices to query.
@ -35,7 +104,7 @@ class DiscoveryService(
timeout: Int = LocalServerDiscovery.DISCOVERY_TIMEOUT,
maxServers: Int = LocalServerDiscovery.DISCOVERY_MAX_SERVERS
) = localServerDiscovery.discover(
timeout,
maxServers
timeout = timeout,
maxServers = maxServers
)
}

View File

@ -40,9 +40,9 @@ class LocalServerDiscovery(
val packet = DatagramPacket(message, message.size, address, DISCOVERY_PORT)
socket.send(packet)
logger.debug("Discovering via %s", address)
logger.debug("Discovering via $address")
} catch (err: IOException) {
logger.error("Unable to send discovery message to %s", address, err)
logger.error("Unable to send discovery message to $address", err)
}
/**
@ -59,7 +59,7 @@ class LocalServerDiscovery(
// Convert message to string
val message = String(packet.data, 0, packet.length)
logger.debug("""Received message "%s"""", message)
logger.debug("""Received message "$message"""")
// Read as JSON
val info = Json.decodeFromString(DiscoveryServerInfo.serializer(), message)
@ -87,7 +87,7 @@ class LocalServerDiscovery(
timeout: Int = DISCOVERY_TIMEOUT,
maxServers: Int = DISCOVERY_MAX_SERVERS
) = flow {
logger.info("Starting discovery with timeout of %sms", timeout)
logger.info("Starting discovery with timeout of ${timeout}ms")
val socket = DatagramSocket().apply {
broadcast = true

View File

@ -0,0 +1,120 @@
package org.jellyfin.apiclient.discovery
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.withContext
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.api.operations.SystemApi
import org.jellyfin.apiclient.model.api.PublicSystemInfo
import org.jellyfin.apiclient.model.discovery.ServerVersion
import org.slf4j.LoggerFactory
import java.net.ConnectException
class RecommendedServerDiscovery(
private val jellyfin: Jellyfin
) {
private val logger = LoggerFactory.getLogger("RecommendedServerDiscovery")
private data class SystemInfoResult(
val address: String,
val systemInfo: PublicSystemInfo?,
val responseTime: Long
)
private suspend fun getSystemInfoResult(address: String): SystemInfoResult? {
logger.info("Requesting public system info for $address")
val client = jellyfin.createApi(baseUrl = address)
val api = SystemApi(client)
val startTime = System.currentTimeMillis()
@Suppress("TooGenericExceptionCaught")
val info = try {
api.getPublicSystemInfo()
} catch (err: ConnectException) {
logger.debug("Could not connect to $address")
null
} catch (err: Exception) {
logger.error("Could not retrieve public system info for $address", err)
null
}
val endTime = System.currentTimeMillis()
return SystemInfoResult(
address = address,
systemInfo = if (info != null && info.status == 200) info.content else null,
responseTime = endTime - startTime,
)
}
private fun assignScore(result: SystemInfoResult, parentResult: SystemInfoResult? = null): RecommendedServerInfo {
var points = 0
// Security
if (result.address.startsWith("https://")) points += 3
// Speed
when {
result.responseTime < 500 -> points += 3
result.responseTime < 1500 -> points += 2
result.responseTime < 5000 -> points += 1
}
// Compatibility
val version = result.systemInfo?.version?.let(ServerVersion::fromString)
if (version != null) {
if (version >= Jellyfin.apiVersion) points += 1
if (version == Jellyfin.recommendedVersion) points += 2
else if (version > Jellyfin.recommendedVersion) points += 1
}
// Minimum amount of points: 0
// Maximum amount of points: 9
val score = when {
points < 3 -> RecommendedServerInfoScore.BAD
points < 6 -> RecommendedServerInfoScore.OK
else -> RecommendedServerInfoScore.GOOD
}
return RecommendedServerInfo(result.address, result.responseTime, score, result.systemInfo, parentResult?.address)
}
suspend fun discover(
servers: List<String>,
includeAppendedServers: Boolean,
minimumScore: RecommendedServerInfoScore
) = discover(
servers = servers.asFlow(),
includeAppendedServers = includeAppendedServers,
minimumScore = minimumScore
)
suspend fun discover(
servers: Flow<String>,
includeAppendedServers: Boolean,
minimumScore: RecommendedServerInfoScore
): Flow<RecommendedServerInfo> = withContext(Dispatchers.IO) {
flow {
servers.onEach parentEach@{ server ->
val info = getSystemInfoResult(server) ?: return@parentEach
if (includeAppendedServers) {
// Check for child server before emitting parent server
info.systemInfo?.localAddress?.let childLet@{ childServerAddress ->
val childInfo = getSystemInfoResult(childServerAddress) ?: return@childLet
// Emit info for child
emit(assignScore(childInfo, info))
}
}
// Emit info for server
emit(assignScore(info))
}.collect()
}.filter {
// Use [minimumScore] to filter out bad score matches
it.score.score >= minimumScore.score
}
}
}

View File

@ -0,0 +1,16 @@
package org.jellyfin.apiclient.discovery
import org.jellyfin.apiclient.model.api.PublicSystemInfo
data class RecommendedServerInfo(
val address: String,
val responseTime: Long,
val score: RecommendedServerInfoScore,
val systemInfo: PublicSystemInfo?,
val parent: String?
) {
/**
* True when this server was not a part of the inputted addresses, false otherwise.
*/
val isAppended = parent != null
}

View File

@ -0,0 +1,9 @@
package org.jellyfin.apiclient.discovery
enum class RecommendedServerInfoScore(
val score: Int
) {
GOOD(1),
OK(0),
BAD(-1)
}

View File

@ -1,9 +1,10 @@
package org.jellyfin.apiclient.discovery
import org.jellyfin.apiclient.Jellyfin
import kotlin.test.Test
class DiscoveryServiceTests {
private fun getInstance() = DiscoveryService(MockDiscoveryBroadcastAddressesProvider())
private fun getInstance() = DiscoveryService(Jellyfin {}, MockDiscoveryBroadcastAddressesProvider())
@Test
fun `getAddressCandidates prefers https`() {

View File

@ -0,0 +1,48 @@
package org.jellyfin.apiclient.model.discovery
import kotlinx.serialization.Serializable
import org.jellyfin.apiclient.model.discovery.ServerVersion.Companion.fromString
/**
* Model to help with Jellyfin server versions.
* Use [fromString] to parse strings. The format is similar to SemVer.
*/
@Serializable
data class ServerVersion(
val major: Int,
val minor: Int,
val patch: Int
) {
operator fun compareTo(other: ServerVersion) = comparator.compare(this, other)
companion object {
private val comparator = compareBy<ServerVersion>(
{ it.major },
{ it.minor },
{ it.patch }
)
/**
* Create an instance of [ServerVersion] from a string. The string must be in the format
* "\d+\.\d+\.\d+\". Example: 1.0.0 or 10.6.4. Characters are not allowed.
*/
fun fromString(str: String): ServerVersion? {
// Split into major, minor and patch
val stringParts = str.split('.')
// Check if we found 3 parts
if (stringParts.size != 3) return null
// Convert to integers and drop bad values
val intParts = stringParts.mapNotNull(String::toIntOrNull)
// Check if we have 3 parts left to make a valid version
if (intParts.size != 3) return null
// Return server version
return ServerVersion(
major = intParts[0],
minor = intParts[1],
patch = intParts[2]
)
}
}
}

View File

@ -0,0 +1,37 @@
package org.jellyfin.apiclient.model.discovery
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ServerVersionTests {
@Test
fun `Parses correct version strings`() {
assertEquals(ServerVersion.fromString("10.6.4"), ServerVersion(10, 6, 4))
assertEquals(ServerVersion.fromString("10.7.0"), ServerVersion(10, 7, 0))
assertEquals(ServerVersion.fromString("1.2.3"), ServerVersion(1, 2, 3))
assertEquals(ServerVersion.fromString("111.222.333"), ServerVersion(111, 222, 333))
}
@Test
fun `Returns null for incorrect version strings`() {
assertNull(ServerVersion.fromString("10.6.4-2"))
assertNull(ServerVersion.fromString("10.6.4.2"))
assertNull(ServerVersion.fromString("10.7"))
assertNull(ServerVersion.fromString("10"))
assertNull(ServerVersion.fromString("test"))
assertNull(ServerVersion.fromString("11.0.0-rc.1"))
}
@Test
fun `Compares to other versions`() {
assertTrue { ServerVersion(10, 6, 0) == ServerVersion(10, 6, 0) }
assertTrue { ServerVersion(10, 6, 0) < ServerVersion(10, 6, 1) }
assertTrue { ServerVersion(10, 6, 0) < ServerVersion(10, 7, 0) }
assertTrue { ServerVersion(10, 6, 0) < ServerVersion(11, 6, 0) }
assertTrue { ServerVersion(1, 2, 3) > ServerVersion(0, 0, 0) }
}
}

View File

@ -26,6 +26,9 @@ dependencies {
// The CLI library
implementation(Dependencies.KotlinX.cli)
// Logging
implementation(Dependencies.Slf4j.simple)
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {

View File

@ -2,7 +2,10 @@ package org.jellyfin.sample.console.cli
import kotlinx.cli.ArgType
import kotlinx.cli.Subcommand
import kotlinx.cli.default
import kotlinx.cli.required
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.runBlocking
import org.jellyfin.apiclient.Jellyfin
import org.jellyfin.apiclient.api.operations.SystemApi
@ -11,8 +14,14 @@ class Ping(
private val jellyfin: Jellyfin
) : Subcommand("ping", "Pings a given server and retrieve basic system information") {
private val server by option(ArgType.String, description = "Url of the server", shortName = "s").required()
private val extended by option(ArgType.Boolean, description = "Find servers based on input using recommended server algorithm", shortName = "e").default(false)
override fun execute() = runBlocking {
if (extended) executeExtended()
else executeSimple()
}
suspend fun executeSimple() {
val api = jellyfin.createApi(baseUrl = server)
val systemApi = SystemApi(api)
@ -22,4 +31,13 @@ class Ping(
println("name: ${result.serverName}")
println("version: ${result.version}")
}
suspend fun executeExtended() {
val servers = jellyfin.discovery.getRecommendedServers(server)
servers.onEach {
println("${it.address}: score=${it.score} duration=${it.responseTime}ms parent(${it.isAppended})=${it.parent}")
println("info=${it.systemInfo}")
println()
}.collect()
}
}