Login screens
This commit is contained in:
@@ -19,13 +19,13 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.discovery.DiscoveryService
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -128,23 +128,30 @@ class JellyfinSession @Inject constructor(
|
||||
true
|
||||
}
|
||||
|
||||
suspend fun login(serverUrl: String, userName: String, password: String) {
|
||||
val url = serverUrl.trim().trimEnd('/')
|
||||
mutex.withLock {
|
||||
routing.set(null, null)
|
||||
api.update(baseUrl = url, accessToken = null)
|
||||
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
|
||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||
api.update(accessToken = token)
|
||||
val serverId = result.serverId ?: user.serverId ?: url
|
||||
val serverName = runCatching { call { systemApi.getPublicSystemInfo().content.serverName } }.getOrNull().orEmpty()
|
||||
val account = Account(serverId, serverName, url, user.id, user.name.orEmpty(), token, lastUsedAt = System.currentTimeMillis())
|
||||
save(account)
|
||||
activate(account)
|
||||
}
|
||||
/** Saves the account of a successful login (see [ServerLogin]) and makes it current. */
|
||||
internal suspend fun signedIn(server: Server, result: AuthenticationResult) = mutex.withLock {
|
||||
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
|
||||
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
|
||||
val account = Account(
|
||||
serverId = result.serverId ?: user.serverId ?: server.id,
|
||||
serverName = server.name,
|
||||
serverUrl = server.url,
|
||||
userId = user.id,
|
||||
userName = user.name.orEmpty(),
|
||||
accessToken = token,
|
||||
localUrl = server.localUrl,
|
||||
lastUsedAt = System.currentTimeMillis(),
|
||||
)
|
||||
save(account)
|
||||
activate(account)
|
||||
}
|
||||
|
||||
internal val discovery: DiscoveryService get() = jellyfin.discovery
|
||||
|
||||
/** A client for the login screens: no token, this device's identity, its own timeouts. */
|
||||
internal fun loginClient(baseUrl: String, options: HttpClientOptions): ApiClient =
|
||||
jellyfin.createApi(baseUrl = baseUrl, deviceInfo = api.deviceInfo, httpClientOptions = options)
|
||||
|
||||
/** Makes a saved account current (profile switch); its token is reused, no password needed. */
|
||||
suspend fun switchTo(accountId: String) = mutex.withLock {
|
||||
val account = _accounts.value.first { it.id == accountId }.copy(lastUsedAt = System.currentTimeMillis())
|
||||
@@ -223,7 +230,7 @@ class JellyfinSession @Inject constructor(
|
||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
|
||||
private companion object {
|
||||
const val CLIENT_NAME = "Movies (prototype)"
|
||||
const val CLIENT_NAME = "Movies"
|
||||
const val CLIENT_VERSION = "0.1.0"
|
||||
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@ import java.net.SocketTimeoutException
|
||||
import java.net.URL
|
||||
|
||||
/**
|
||||
* Finds a direct LAN address of the logged-in server, for streaming.
|
||||
* Finds Jellyfin servers on the local network: for the login screen, and the direct LAN address of the logged-in
|
||||
* server for streaming.
|
||||
*
|
||||
* At home the TV reached the server through its public HTTPS name: router hairpin, reverse proxy, and TLS decrypted
|
||||
* by the TV's CPU. That capped Exo at 40–115 Mbit/s and a 66 Mbit/s 4K remux rebuffered every
|
||||
@@ -28,6 +29,9 @@ internal object LocalServerDiscovery {
|
||||
private const val TIMEOUT_MS = 1500
|
||||
private const val MESSAGE = "Who is JellyfinServer?"
|
||||
|
||||
/** A reply to the discovery broadcast. */
|
||||
class Reply(val id: String, val name: String, val lanUrl: String, val advertisedUrl: String?)
|
||||
|
||||
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
||||
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
||||
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
|
||||
@@ -37,36 +41,43 @@ internal object LocalServerDiscovery {
|
||||
|
||||
/** `http://<ip>:8096` of the server with [serverId] on the local network, or null. */
|
||||
suspend fun find(serverId: String): String? = withContext(Dispatchers.IO) {
|
||||
val candidates = runCatching { discover(serverId) }.onFailure { Timber.d(it, "Jellyfin discovery failed") }.getOrDefault(emptyList())
|
||||
candidates.firstOrNull { url -> serverId(url) == serverId }
|
||||
val reply = discover(stopAt = serverId).firstOrNull { it.id == serverId } ?: return@withContext null
|
||||
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
|
||||
listOfNotNull(reply.lanUrl, reply.advertisedUrl?.takeIf { it.startsWith("http://") }).distinct()
|
||||
.firstOrNull { url -> serverId(url) == serverId }
|
||||
}
|
||||
|
||||
private fun discover(serverId: String): List<String> {
|
||||
val found = mutableListOf<String>()
|
||||
DatagramSocket().use { socket ->
|
||||
socket.broadcast = true
|
||||
socket.soTimeout = TIMEOUT_MS
|
||||
val message = MESSAGE.toByteArray()
|
||||
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
|
||||
val buffer = ByteArray(4096)
|
||||
val deadline = System.currentTimeMillis() + TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
try {
|
||||
socket.receive(packet)
|
||||
} catch (_: SocketTimeoutException) {
|
||||
break
|
||||
/** Every server that answers within [TIMEOUT_MS], one reply per server id. */
|
||||
suspend fun discoverAll(): List<Reply> = withContext(Dispatchers.IO) { discover(stopAt = null) }
|
||||
|
||||
private fun discover(stopAt: String?): List<Reply> {
|
||||
val found = LinkedHashMap<String, Reply>()
|
||||
runCatching {
|
||||
DatagramSocket().use { socket ->
|
||||
socket.broadcast = true
|
||||
socket.soTimeout = TIMEOUT_MS
|
||||
val message = MESSAGE.toByteArray()
|
||||
socket.send(DatagramPacket(message, message.size, InetAddress.getByName("255.255.255.255"), DISCOVERY_PORT))
|
||||
val buffer = ByteArray(4096)
|
||||
val deadline = System.currentTimeMillis() + TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val packet = DatagramPacket(buffer, buffer.size)
|
||||
try {
|
||||
socket.receive(packet)
|
||||
} catch (_: SocketTimeoutException) {
|
||||
break
|
||||
}
|
||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
|
||||
val host = packet.address.hostAddress ?: continue
|
||||
found.getOrPut(id) {
|
||||
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") })
|
||||
}
|
||||
if (id == stopAt) break
|
||||
}
|
||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
if (reply.optString("Id") != serverId) continue
|
||||
val host = packet.address.hostAddress ?: continue
|
||||
found += "http://$host:$HTTP_PORT"
|
||||
// The advertised address, if it happens to be a plain LAN URL, is worth a try as well.
|
||||
reply.optString("Address").takeIf { it.startsWith("http://") }?.let { found += it.trimEnd('/') }
|
||||
break
|
||||
}
|
||||
}
|
||||
return found.distinct()
|
||||
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
private fun get(url: String): String {
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package ru.stenfredd.movies.core.jellyfin
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
|
||||
import org.jellyfin.sdk.api.client.extensions.authenticationApi
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.api.QuickConnectDto
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** A Jellyfin server to log in to. */
|
||||
data class Server(
|
||||
val id: String,
|
||||
val name: String,
|
||||
/** Address the account is saved with: the public name when the server has one that works, else the LAN address. */
|
||||
val url: String,
|
||||
/** Direct LAN address, when the server was found on the local network. */
|
||||
val localUrl: String? = null,
|
||||
val version: String? = null,
|
||||
/** Round trip of `/System/Info/Public`, ms; null when not measured. */
|
||||
val pingMs: Long? = null,
|
||||
) {
|
||||
/** Where login requests go: the LAN address at home (no hairpin, no TLS on the TV's CPU). */
|
||||
val requestUrl: String get() = localUrl ?: url
|
||||
}
|
||||
|
||||
/** A user the server shows on its login screen (`/Users/Public`). */
|
||||
data class PublicUser(val id: UUID, val name: String, val hasPassword: Boolean)
|
||||
|
||||
/** A Quick Connect request: [code] is typed (or scanned) on a device where the user is logged in. */
|
||||
data class QuickConnectCode(val code: String, val secret: String)
|
||||
|
||||
/** The server forgot a Quick Connect request (it expires after a few minutes); start a new one. */
|
||||
class QuickConnectExpiredException : Exception("Quick Connect request expired")
|
||||
|
||||
/**
|
||||
* Everything the login screens need before there is an account: servers on the local network, a server by its
|
||||
* address, its public users, password and Quick Connect login. A successful login becomes the current account of
|
||||
* [JellyfinSession].
|
||||
*/
|
||||
@Singleton
|
||||
class ServerLogin @Inject constructor(private val session: JellyfinSession) {
|
||||
|
||||
/** Servers on the local network with version and ping; empty when there are none (or no network). */
|
||||
suspend fun discover(): List<Server> = coroutineScope {
|
||||
LocalServerDiscovery.discoverAll().map { reply ->
|
||||
async {
|
||||
val lan = probe(reply.lanUrl) ?: return@async null
|
||||
if (lan.id != reply.id) return@async null
|
||||
// The address the server advertises (its public name, if configured) is saved with the account so that
|
||||
// it also works away from home; the LAN address is used while at home.
|
||||
val public = reply.advertisedUrl?.takeIf { it != reply.lanUrl && it.startsWith("https://") }
|
||||
?.let { url -> probe(url)?.takeIf { it.id == reply.id }?.url }
|
||||
lan.copy(name = lan.name.ifEmpty { reply.name }, url = public ?: lan.url, localUrl = reply.lanUrl)
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
|
||||
/** Version and ping of the server at [url], or null when it doesn't answer as a Jellyfin server. */
|
||||
suspend fun probe(url: String): Server? = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val api = client(url, quick = true).systemApi
|
||||
val info = api.getPublicSystemInfo().content
|
||||
// The first request also opened the connection (and TLS); the ping is a second one on it.
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
api.getPublicSystemInfo()
|
||||
Server(
|
||||
id = checkNotNull(info.id),
|
||||
name = info.serverName.orEmpty(),
|
||||
url = url,
|
||||
version = info.version,
|
||||
pingMs = SystemClock.elapsedRealtime() - started,
|
||||
)
|
||||
}.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* The server at an address typed by the user: `192.168.1.10`, `movies.example.com`, `https://…:8920/jellyfin`.
|
||||
* Tries the usual schemes and ports (SDK address candidates) and picks the best answer.
|
||||
* Throws [ServerNotFoundException] when nothing answers.
|
||||
*/
|
||||
suspend fun find(input: String): Server = withContext(Dispatchers.IO) {
|
||||
val text = input.trim().trimEnd('/')
|
||||
if (text.isEmpty()) throw ServerNotFoundException()
|
||||
val best = session.discovery.getRecommendedServers(text)
|
||||
.filter { it.score != RecommendedServerInfoScore.BAD && it.systemInfo.isSuccess }
|
||||
.minWithOrNull(compareBy({ it.score.ordinal }, { it.responseTime }))
|
||||
?: throw ServerNotFoundException()
|
||||
val info = best.systemInfo.getOrThrow()
|
||||
Server(
|
||||
id = info.id ?: best.address,
|
||||
name = info.serverName.orEmpty(),
|
||||
url = best.address.trimEnd('/'),
|
||||
version = info.version,
|
||||
pingMs = best.responseTime,
|
||||
)
|
||||
}
|
||||
|
||||
/** Users listed on the server's login screen; empty when the server hides them. */
|
||||
suspend fun publicUsers(server: Server): List<PublicUser> = withContext(Dispatchers.IO) {
|
||||
client(server.requestUrl).userApi.getPublicUsers().content.map {
|
||||
PublicUser(it.id, it.name.orEmpty(), it.hasPassword ?: true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs in and makes the account current. Throws [InvalidStatusException] 401 on a wrong name or password. */
|
||||
suspend fun login(server: Server, userName: String, password: String) {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
client(server.requestUrl).authenticationApi.authenticateUserByName(userName.trim(), password).content
|
||||
}
|
||||
session.signedIn(server, result)
|
||||
}
|
||||
|
||||
/** Starts a Quick Connect request, or returns null when Quick Connect is turned off on the server. */
|
||||
suspend fun startQuickConnect(server: Server): QuickConnectCode? = withContext(Dispatchers.IO) {
|
||||
val api = client(server.requestUrl).authenticationApi
|
||||
if (!api.getQuickConnectEnabled().content) return@withContext null
|
||||
val result = api.initiateQuickConnect().content
|
||||
QuickConnectCode(result.code, result.secret)
|
||||
}
|
||||
|
||||
/** Whether the request was approved; throws [QuickConnectExpiredException] once the server has dropped it. */
|
||||
suspend fun isQuickConnectApproved(server: Server, secret: String): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
client(server.requestUrl).authenticationApi.getQuickConnectState(secret).content.authenticated
|
||||
} catch (e: InvalidStatusException) {
|
||||
if (e.status == 404) throw QuickConnectExpiredException() else throw e
|
||||
}
|
||||
}
|
||||
|
||||
/** Finishes an approved Quick Connect request: logs in and makes the account current. */
|
||||
suspend fun loginQuickConnect(server: Server, secret: String) {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
client(server.requestUrl).authenticationApi.authenticateWithQuickConnect(QuickConnectDto(secret)).content
|
||||
}
|
||||
session.signedIn(server, result)
|
||||
}
|
||||
|
||||
/** A client without a token, with this device's identity (a Quick Connect token is bound to the device id). */
|
||||
private fun client(url: String, quick: Boolean = false): ApiClient =
|
||||
session.loginClient(url, if (quick) PROBE_OPTIONS else LOGIN_OPTIONS)
|
||||
|
||||
private companion object {
|
||||
val PROBE_OPTIONS = HttpClientOptions(connectTimeout = 2.seconds, requestTimeout = 3.seconds, socketTimeout = 3.seconds)
|
||||
val LOGIN_OPTIONS = HttpClientOptions(connectTimeout = 5.seconds, requestTimeout = 15.seconds, socketTimeout = 15.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
class ServerNotFoundException : Exception("Server not found")
|
||||
Reference in New Issue
Block a user