Data layer and baseline profile

This commit is contained in:
2026-10-14 11:52:33 +03:00
parent b86da05fee
commit c7d29e94ea
30 changed files with 51025 additions and 231 deletions

View File

@@ -17,6 +17,7 @@ android {
dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.android)
api(libs.okhttp)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)

View File

@@ -0,0 +1,112 @@
package ru.stenfredd.movies.core.jellyfin
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
data class Account(
val serverId: String,
val serverName: String,
/** Address the user entered (usually the public HTTPS name); API calls are addressed to it. */
val serverUrl: String,
val userId: UUID,
val userName: String,
val accessToken: String,
/** Direct LAN address of the same server found last time, see [LocalServerDiscovery]; tried first on the next start. */
val localUrl: String? = null,
val lastUsedAt: Long = 0,
) {
val id: String get() = accountId(serverId, userId)
internal fun toJson(): JSONObject = JSONObject()
.put("serverId", serverId)
.put("serverName", serverName)
.put("serverUrl", serverUrl)
.put("userId", userId.toString())
.put("userName", userName)
.put("token", accessToken)
.put("localUrl", localUrl ?: JSONObject.NULL)
.put("lastUsedAt", lastUsedAt)
internal companion object {
fun fromJson(o: JSONObject) = Account(
serverId = o.getString("serverId"),
serverName = o.optString("serverName"),
serverUrl = o.getString("serverUrl"),
userId = UUID.fromString(o.getString("userId")),
userName = o.optString("userName"),
accessToken = o.getString("token"),
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
lastUsedAt = o.optLong("lastUsedAt"),
)
}
}
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
internal class AccountStore(private val context: Context) {
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
suspend fun read(): Saved {
val prefs = context.sessionStore.data.first()
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
}
suspend fun setDeviceId(id: String) {
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
}
/** Applies [change] to the saved list and sets the current account; returns the new list. */
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
var result = emptyList<Account>()
context.sessionStore.edit { prefs ->
result = change(parse(prefs))
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
}
return result
}
/** The single session saved before accounts existed (prototype); null when there is none. */
suspend fun legacySession(): LegacySession? {
val prefs = context.sessionStore.data.first()
return LegacySession(
url = prefs[LEGACY_URL] ?: return null,
token = prefs[LEGACY_TOKEN] ?: return null,
userId = prefs[LEGACY_USER_ID]?.let(UUID::fromString) ?: return null,
userName = prefs[LEGACY_USER_NAME].orEmpty(),
)
}
suspend fun dropLegacySession() {
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
}
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
private fun parse(prefs: Preferences): List<Account> {
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
}
private companion object {
val KEY_ACCOUNTS = stringPreferencesKey("accounts")
val KEY_CURRENT = stringPreferencesKey("current_account")
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
val LEGACY_URL = stringPreferencesKey("server_url")
val LEGACY_TOKEN = stringPreferencesKey("access_token")
val LEGACY_USER_ID = stringPreferencesKey("user_id")
val LEGACY_USER_NAME = stringPreferencesKey("user_name")
}
}

View File

@@ -0,0 +1,23 @@
package ru.stenfredd.movies.core.jellyfin
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import javax.inject.Singleton
/** One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting]. */
@Module
@InstallIn(SingletonComponent::class)
object HttpModule {
@Provides
@Singleton
fun serverRouting(): ServerRouting = ServerRouting()
@Provides
@Singleton
fun okHttpClient(routing: ServerRouting): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(routing)
.build()
}

View File

@@ -3,22 +3,26 @@ package ru.stenfredd.movies.core.jellyfin
import android.content.Context
import android.os.Build
import android.os.SystemClock
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
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.okhttp.OkHttpFactory
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
@@ -27,95 +31,133 @@ import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
data class SessionInfo(
val accountId: String,
val serverId: String,
val serverUrl: String,
val userId: UUID,
val userName: String,
val accessToken: String,
)
/** Holds the single authenticated [ApiClient]; persists the session across restarts. */
/**
* Holds the authenticated [ApiClient] of the current account and the accounts saved on the device.
*
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
*/
@Singleton
class JellyfinSession @Inject constructor(
@ApplicationContext private val context: Context,
httpClient: OkHttpClient,
private val routing: ServerRouting,
) {
private val store = AccountStore(context)
private val jellyfin = createJellyfin {
context = this@JellyfinSession.context
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
apiClientFactory = OkHttpFactory(httpClient)
}
private val mutex = Mutex()
private val _session = MutableStateFlow<SessionInfo?>(null)
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
private val _accounts = MutableStateFlow<List<Account>>(emptyList())
/** Saved accounts, most recently used first. */
val accounts: StateFlow<List<Account>> = _accounts.asStateFlow()
private val _userDataChanges = MutableSharedFlow<UUID>(extraBufferCapacity = 16)
/** Items whose watch state, progress or favourite flag just changed on the server (playback stopped, marked …). */
val userDataChanges: SharedFlow<UUID> = _userDataChanges.asSharedFlow()
lateinit var api: ApiClient
private set
val deviceId: String get() = api.deviceInfo.id
/** Direct LAN address of the same server for media streams, see [LocalServerDiscovery]; null away from home. */
@Volatile
private var localUrl: String? = null
@Volatile
private var localCheckedAt = 0L
private val discoveryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val backgroundScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
init {
routing.onLocalFailed = {
localCheckedAt = 0
refreshLocalAddress()
}
}
/** Direct LAN address of the current server, or null (away from home, or not found yet). */
val localUrl: String? get() = routing.localUrl
/**
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]); playback uses
* whatever is known at that moment and never waits for it.
* Looks for the server on the local network in the background (at most every [LOCAL_RECHECK_MS]). Requests use
* whatever is known at that moment and never wait for it; the address found is remembered for the next start.
*/
fun refreshLocalAddress() {
val s = _session.value ?: return
val now = SystemClock.elapsedRealtime()
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
localCheckedAt = now
discoveryScope.launch {
val id = LocalServerDiscovery.serverId(s.serverUrl)
val found = id?.let { LocalServerDiscovery.find(it) }
if (found != localUrl) Timber.i("Media streams: %s", found ?: "через ${s.serverUrl}")
localUrl = found
backgroundScope.launch {
val found = LocalServerDiscovery.find(s.serverId)
if (_session.value?.accountId != s.accountId) return@launch
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
routing.set(s.serverUrl, found)
mutex.withLock {
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
_accounts.value = store.update(s.accountId) { list -> list.map { if (it.id == s.accountId) it.copy(localUrl = found) else it } }
}
}
}
}
/** Restores the saved session. Returns false when the user has to log in. */
suspend fun restore(): Boolean {
val prefs = context.sessionStore.data.first()
api = jellyfin.createApi(deviceInfo = deviceInfo(prefs[KEY_DEVICE_ID] ?: newDeviceId()))
val url = prefs[KEY_URL] ?: return false
val token = prefs[KEY_TOKEN] ?: return false
val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false
api.update(baseUrl = url, accessToken = token)
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
refreshLocalAddress()
return true
/** Restores the current account. Returns false when the user has to log in. */
suspend fun restore(): Boolean = mutex.withLock {
val saved = store.read()
api = jellyfin.createApi(deviceInfo = deviceInfo(saved.deviceId ?: newDeviceId()))
var accounts = saved.accounts
var currentId = saved.currentId
if (accounts.isEmpty()) migrateLegacy()?.let { account ->
accounts = listOf(account)
currentId = account.id
}
_accounts.value = accounts.sortedByDescending { it.lastUsedAt }
val current = accounts.firstOrNull { it.id == currentId } ?: return false
activate(current)
true
}
suspend fun login(serverUrl: String, userName: String, password: String) {
val url = serverUrl.trim().trimEnd('/')
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)
context.sessionStore.edit {
it[KEY_URL] = url
it[KEY_TOKEN] = token
it[KEY_USER_ID] = user.id.toString()
it[KEY_USER_NAME] = user.name.orEmpty()
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)
}
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
localUrl = null
localCheckedAt = 0
refreshLocalAddress()
}
suspend fun logout() {
context.sessionStore.edit {
it.remove(KEY_TOKEN)
it.remove(KEY_USER_ID)
}
/** 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())
save(account)
activate(account)
}
/** Signs out of the current account and forgets it on this device. */
suspend fun logout() = mutex.withLock {
val current = _session.value ?: return@withLock
_accounts.value = store.update(currentId = null) { list -> list.filterNot { it.id == current.accountId } }
api.update(accessToken = null)
routing.set(null, null)
_session.value = null
}
@@ -124,13 +166,19 @@ class JellyfinSession @Inject constructor(
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
/** Tells screens that [itemId]'s user data changed on the server, so cached lists (resume, next up …) reload. */
fun notifyUserDataChanged(itemId: UUID) {
_userDataChanges.tryEmit(itemId)
}
/**
* Absolute URL for a server-relative path or a URL on the server, with the token as `ApiKey` (engines can't send
* headers). Media goes to the LAN address when one was found; API calls keep using [SessionInfo.serverUrl].
* headers). Media goes to the LAN address when one is known; engines use their own HTTP stacks, so the address is
* substituted here rather than by [ServerRouting].
*/
fun authorizedUrl(pathOrUrl: String): String {
val s = requireSession()
val base = localUrl ?: s.serverUrl
val base = routing.localUrl ?: s.serverUrl
val absolute = when {
pathOrUrl.startsWith(s.serverUrl) -> base + pathOrUrl.removePrefix(s.serverUrl)
pathOrUrl.startsWith("http") -> pathOrUrl
@@ -141,9 +189,34 @@ class JellyfinSession @Inject constructor(
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
}
private fun activate(account: Account) {
api.update(baseUrl = account.serverUrl, accessToken = account.accessToken)
// The LAN address from last time is used right away; a wrong one costs one fast failed connect.
routing.set(account.serverUrl, account.localUrl)
_session.value = SessionInfo(account.id, account.serverId, account.serverUrl, account.userId, account.userName, account.accessToken)
localCheckedAt = 0
refreshLocalAddress()
}
private suspend fun save(account: Account) {
_accounts.value = store.update(account.id) { list -> (listOf(account) + list.filterNot { it.id == account.id }) }
.sortedByDescending { it.lastUsedAt }
}
/** The prototype kept one session in separate keys; turn it into the first saved account. */
private suspend fun migrateLegacy(): Account? {
val legacy = store.legacySession() ?: return null
val serverId = LocalServerDiscovery.serverId(legacy.url) ?: legacy.url
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
store.update(account.id) { listOf(account) }
store.dropLegacySession()
Timber.i("Migrated the saved session to account %s", account.id)
return account
}
private suspend fun newDeviceId(): String {
val id = UUID.randomUUID().toString()
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
store.setDeviceId(id)
return id
}
@@ -152,11 +225,6 @@ class JellyfinSession @Inject constructor(
private companion object {
const val CLIENT_NAME = "Movies (prototype)"
const val CLIENT_VERSION = "0.1.0"
val KEY_URL = stringPreferencesKey("server_url")
val KEY_TOKEN = stringPreferencesKey("access_token")
val KEY_USER_ID = stringPreferencesKey("user_id")
val KEY_USER_NAME = stringPreferencesKey("user_name")
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
const val LOCAL_RECHECK_MS = 10 * 60_000L
}
}

View File

@@ -0,0 +1,73 @@
package ru.stenfredd.movies.core.jellyfin
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import timber.log.Timber
import java.io.IOException
import java.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.util.concurrent.TimeUnit
/**
* Sends requests for the server's public address to its LAN address when one is known.
*
* API calls and images are built on the public address (it is also the image cache key, so the cache stays valid
* at home and away); at home this interceptor swaps the origin to the plain-HTTP LAN address, which skips the router
* hairpin, the reverse proxy and TLS on the TV's weak CPU. If the LAN address can't be connected to, the request goes
* to the public address and [onLocalFailed] lets the session look for the server again.
*/
class ServerRouting internal constructor() : Interceptor {
private class Route(val public: HttpUrl, val local: HttpUrl)
@Volatile
private var route: Route? = null
internal var onLocalFailed: (() -> Unit)? = null
val localUrl: String? get() = route?.local?.toString()?.trimEnd('/')
internal fun set(publicUrl: String?, localUrl: String?) {
val public = publicUrl?.toHttpUrlOrNull()
val local = localUrl?.toHttpUrlOrNull()
route = if (public != null && local != null) Route(public, local) else null
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val r = route ?: return chain.proceed(request)
val local = rewrite(request.url, r) ?: return chain.proceed(request)
return try {
// A LAN address that isn't there (away from home) must fail fast, not after the default 10 s.
chain.withConnectTimeout(LOCAL_CONNECT_TIMEOUT_MS, TimeUnit.MILLISECONDS).proceed(request.newBuilder().url(local).build())
} catch (e: IOException) {
// Only failures to connect are safe to repeat: the request never reached the server.
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
if (route === r) route = null
onLocalFailed?.invoke()
chain.proceed(request)
}
}
private fun rewrite(url: HttpUrl, r: Route): HttpUrl? {
if (url.host != r.public.host || url.port != r.public.port || url.scheme != r.public.scheme) return null
val prefix = r.public.encodedPath.trimEnd('/')
val path = url.encodedPath
if (!path.startsWith(prefix)) return null
val tail = path.removePrefix(prefix)
val base = r.local.toString().trimEnd('/')
val query = url.encodedQuery?.let { "?$it" }.orEmpty()
return "$base$tail$query".toHttpUrlOrNull()
}
private fun IOException.isConnectFailure() = this is ConnectException || this is NoRouteToHostException ||
this is UnknownHostException || (this is SocketTimeoutException && message?.contains("connect", ignoreCase = true) == true)
private companion object {
const val LOCAL_CONNECT_TIMEOUT_MS = 1500
}
}