mirror of
https://github.com/jellyfin/jellyfin-sdk-kotlin.git
synced 2025-02-18 15:18:25 +00:00
Merge pull request #134 from nielsvanvelzen/recommended-servers
Add recommended server discovery
This commit is contained in:
commit
d5ec4e8e9f
@ -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")
|
||||
}
|
||||
|
||||
|
@ -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")
|
||||
|
@ -1,6 +0,0 @@
|
||||
package org.jellyfin.apiclient
|
||||
|
||||
data class AppInfo(
|
||||
val name: String,
|
||||
val version: String
|
||||
)
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
@ -0,0 +1,9 @@
|
||||
package org.jellyfin.apiclient.discovery
|
||||
|
||||
enum class RecommendedServerInfoScore(
|
||||
val score: Int
|
||||
) {
|
||||
GOOD(1),
|
||||
OK(0),
|
||||
BAD(-1)
|
||||
}
|
@ -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`() {
|
||||
|
@ -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]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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) }
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user