Login screens

This commit is contained in:
2026-10-14 12:20:48 +03:00
parent c7d29e94ea
commit c56a74a3a0
18 changed files with 1595 additions and 183 deletions

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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")