Shared KMP core and UI, switch to Koin
This commit is contained in:
25
core/common/build.gradle.kts
Normal file
25
core/common/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
// Shared by the TV (android) and desktop versions: logging, clock, preference files.
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.common"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.androidx.datastore.preferences.core)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/** The places the TV version always used: `files/datastore` (as `preferencesDataStore`) and `files`. */
|
||||
fun androidAppDirs(context: Context) = AppDirs(preferences = File(context.filesDir, "datastore"), files = context.filesDir)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
actual fun monotonicMs(): Long = SystemClock.elapsedRealtime()
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
internal actual fun logEnabled() = Timber.treeCount > 0
|
||||
|
||||
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||
Timber.tag(tag).log(priority, t, message, *args)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Where the app keeps its files on this platform.
|
||||
*
|
||||
* [preferences]: DataStore files (`<name>.preferences_pb`) — accounts, settings, remembered tracks. [files]: data that
|
||||
* can be fetched again but should outlive a restart, such as the home screen snapshot.
|
||||
*/
|
||||
class AppDirs(val preferences: File, val files: File)
|
||||
|
||||
/** The DataStore of each preferences file: DataStore allows one instance per file, so everyone gets it here. */
|
||||
class PreferenceStores(private val dirs: AppDirs) {
|
||||
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
|
||||
|
||||
operator fun get(name: String): DataStore<Preferences> = stores.computeIfAbsent(name) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { File(dirs.preferences, "$name.preferences_pb") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
/** Milliseconds of a clock that only goes forward; on Android it keeps counting while the device sleeps. */
|
||||
expect fun monotonicMs(): Long
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
/**
|
||||
* The app's log on every platform. Like Timber: [message] is a `String.format` pattern, formatted only when [args]
|
||||
* are given, and the tag is the calling class. Android writes through Timber, desktop to stderr.
|
||||
*/
|
||||
object Log {
|
||||
const val DEBUG = 3
|
||||
const val INFO = 4
|
||||
const val WARN = 5
|
||||
const val ERROR = 6
|
||||
|
||||
fun d(message: String, vararg args: Any?) = write(DEBUG, null, message, args)
|
||||
fun d(t: Throwable?, message: String, vararg args: Any?) = write(DEBUG, t, message, args)
|
||||
fun i(message: String, vararg args: Any?) = write(INFO, null, message, args)
|
||||
fun i(t: Throwable?, message: String, vararg args: Any?) = write(INFO, t, message, args)
|
||||
fun w(message: String, vararg args: Any?) = write(WARN, null, message, args)
|
||||
fun w(t: Throwable?, message: String, vararg args: Any?) = write(WARN, t, message, args)
|
||||
fun e(message: String, vararg args: Any?) = write(ERROR, null, message, args)
|
||||
fun e(t: Throwable?, message: String, vararg args: Any?) = write(ERROR, t, message, args)
|
||||
|
||||
private fun write(priority: Int, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||
if (logEnabled()) platformLog(priority, callerTag(), t, message, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple name of the class that called [Log], without `$1` of lambdas — the tag Timber's DebugTree gave. Our own
|
||||
* frames are told apart by the class name at run time, as Timber does: R8 may merge [Log] into another class.
|
||||
*/
|
||||
private fun callerTag(): String {
|
||||
val own = javaClass.name
|
||||
val caller = Throwable().stackTrace.firstOrNull { it.className != own } ?: return "SleepyOwl"
|
||||
return caller.className.substringAfterLast('.').replace(ANONYMOUS_CLASS, "")
|
||||
}
|
||||
|
||||
private val ANONYMOUS_CLASS = Regex("(\\$\\d+)+$")
|
||||
}
|
||||
|
||||
internal expect fun logEnabled(): Boolean
|
||||
|
||||
internal expect fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>)
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import java.io.File
|
||||
|
||||
/** XDG places: settings in `~/.config/sleepyowl`, refetchable data in `~/.cache/sleepyowl`. */
|
||||
fun desktopAppDirs(): AppDirs {
|
||||
fun xdg(variable: String, default: String) =
|
||||
File(System.getenv(variable)?.takeIf { it.isNotBlank() } ?: (System.getProperty("user.home") + "/" + default), "sleepyowl")
|
||||
return AppDirs(preferences = xdg("XDG_CONFIG_HOME", ".config"), files = xdg("XDG_CACHE_HOME", ".cache"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
actual fun monotonicMs(): Long = System.nanoTime() / 1_000_000
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
|
||||
private val LEVELS = mapOf(Log.DEBUG to 'D', Log.INFO to 'I', Log.WARN to 'W', Log.ERROR to 'E')
|
||||
|
||||
internal actual fun logEnabled() = true
|
||||
|
||||
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||
val text = if (args.isEmpty()) message else message.format(*args)
|
||||
val line = "${LocalTime.now().format(TIME)} ${LEVELS[priority]} $tag: $text"
|
||||
synchronized(System.err) {
|
||||
System.err.println(line)
|
||||
t?.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.data"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.data"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/** Koin: repositories and caches of the shared core (see [ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule]). */
|
||||
val dataModule = module {
|
||||
singleOf(::SessionCache)
|
||||
singleOf(::LibraryRepository)
|
||||
singleOf(::WatchRepository)
|
||||
singleOf(::HomeRepository)
|
||||
singleOf(::UserSettingsRepository)
|
||||
singleOf(::ImageUrls)
|
||||
singleOf(::SearchHistory)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.stenfredd.sleepyowl.core.common.AppDirs
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class HomeContent(
|
||||
@@ -47,9 +44,8 @@ data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, va
|
||||
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
|
||||
* cost the server ~0.2 s each, so they come last, in a separate update.
|
||||
*/
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
class HomeRepository(
|
||||
dirs: AppDirs,
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
|
||||
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
||||
|
||||
private val remembered = ConcurrentHashMap<String, Remembered>()
|
||||
private val dir = File(context.filesDir, "home")
|
||||
private val dir = File(dirs.files, "home")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
|
||||
val known = remembered[account]
|
||||
var shown = known?.content ?: readSnapshot(account)
|
||||
if (shown != null) emit(shown)
|
||||
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||
if (known == null || monotonicMs() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||
cache.invalidations.collect { shown = refresh(account, shown) }
|
||||
}.flowOn(Dispatchers.Default)
|
||||
|
||||
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (shown == null) throw e
|
||||
Timber.w(e, "Home refresh failed, keeping what is shown")
|
||||
Log.w(e, "Home refresh failed, keeping what is shown")
|
||||
shown
|
||||
}
|
||||
|
||||
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
||||
val fresh = coroutineScope {
|
||||
val continueWatching = async { watch.continueWatching() }
|
||||
@@ -109,7 +105,7 @@ class HomeRepository @Inject constructor(
|
||||
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
|
||||
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
|
||||
}
|
||||
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
Log.i("Home fetched in %d ms", monotonicMs() - started)
|
||||
emit(fresh)
|
||||
remember(account, fresh)
|
||||
|
||||
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun remember(account: String, content: HomeContent) {
|
||||
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
|
||||
remembered[account] = Remembered(content, monotonicMs())
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
dir.mkdirs()
|
||||
val tmp = File(dir, "${fileName(account)}.tmp")
|
||||
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
|
||||
tmp.renameTo(File(dir, "${fileName(account)}.json"))
|
||||
}.onFailure { Timber.w(it, "Home snapshot not saved") }
|
||||
}.onFailure { Log.w(it, "Home snapshot not saved") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
|
||||
val file = File(dir, "${fileName(account)}.json")
|
||||
if (!file.exists()) return@withContext null
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
|
||||
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
|
||||
.onFailure { Timber.w(it, "Home snapshot unreadable") }
|
||||
.onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
|
||||
.onFailure { Log.w(it, "Home snapshot unreadable") }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
|
||||
@@ -14,8 +12,7 @@ import javax.inject.Singleton
|
||||
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
|
||||
* request itself goes to the LAN address (`ServerRouting`).
|
||||
*/
|
||||
@Singleton
|
||||
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
|
||||
class ImageUrls(private val session: JellyfinSession) {
|
||||
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
||||
session.api.imageApi.getItemImageUrl(
|
||||
itemId = ref.itemId,
|
||||
@@ -25,12 +25,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Person
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
|
||||
@Singleton
|
||||
class LibraryRepository @Inject constructor(
|
||||
class LibraryRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
@@ -1,30 +1,29 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.content.Context
|
||||
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.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.searchStore by preferencesDataStore("search")
|
||||
|
||||
/** Recent search queries, kept on the device per account, newest first. */
|
||||
@Singleton
|
||||
class SearchHistory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class SearchHistory(
|
||||
stores: PreferenceStores,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
private val searchStore = stores["search"]
|
||||
|
||||
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
|
||||
|
||||
fun recent(): Flow<List<String>> {
|
||||
val key = key()
|
||||
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
return searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
}
|
||||
|
||||
/** Puts [query] first; the same query typed in another case replaces the old one. */
|
||||
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return
|
||||
val key = key()
|
||||
context.searchStore.edit { prefs ->
|
||||
searchStore.edit { prefs ->
|
||||
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
|
||||
prefs[key] = JSONArray(list.take(LIMIT)).toString()
|
||||
prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(raw: String): List<String> = runCatching {
|
||||
val array = JSONArray(raw)
|
||||
List(array.length()) { array.getString(it) }
|
||||
Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private companion object {
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
|
||||
@@ -29,8 +27,7 @@ import javax.inject.Singleton
|
||||
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
|
||||
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
class SessionCache(private val session: JellyfinSession) {
|
||||
enum class Kind(val ttlMs: Long) {
|
||||
/** Lists with watch state: resume, next up, latest, details. */
|
||||
UserData(10 * 60_000L),
|
||||
@@ -64,7 +61,7 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
|
||||
val scoped = "${session.session.value?.accountId}:$key"
|
||||
while (true) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val now = monotonicMs()
|
||||
val mine = Entry(kind, now, CompletableDeferred())
|
||||
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
|
||||
if (entry !== mine) {
|
||||
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
private fun drop(kind: Kind) {
|
||||
val before = entries.size
|
||||
entries.entries.removeAll { it.value.kind == kind }
|
||||
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||
Log.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
|
||||
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
|
||||
/** Server name, version and address for the settings screen. */
|
||||
data class ServerInfo(val name: String, val version: String?)
|
||||
|
||||
@Singleton
|
||||
class UserSettingsRepository @Inject constructor(
|
||||
class UserSettingsRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
@@ -16,12 +16,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Page
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
|
||||
@Singleton
|
||||
class WatchRepository @Inject constructor(
|
||||
class WatchRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
@@ -1,27 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":core:common"))
|
||||
api(libs.jellyfin.sdk)
|
||||
api(libs.okhttp)
|
||||
api(libs.koin.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
// The SDK logs through slf4j but does not ship it.
|
||||
runtimeOnly(libs.slf4j.api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
implementation(libs.timber)
|
||||
// The SDK logs through slf4j but does not ship it.
|
||||
runtimeOnly(libs.slf4j.api)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import org.jellyfin.sdk.JellyfinOptions
|
||||
|
||||
class AndroidJellyfinPlatform(private val context: Context) : JellyfinPlatform {
|
||||
override val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
|
||||
override fun configure(options: JellyfinOptions.Builder) {
|
||||
options.context = context
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
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 kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.UUID
|
||||
|
||||
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
|
||||
@@ -25,53 +32,55 @@ data class Account(
|
||||
) {
|
||||
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 fun toJson(): JsonObject = buildJsonObject {
|
||||
put("serverId", serverId)
|
||||
put("serverName", serverName)
|
||||
put("serverUrl", serverUrl)
|
||||
put("userId", userId.toString())
|
||||
put("userName", userName)
|
||||
put("token", accessToken)
|
||||
put("localUrl", localUrl)
|
||||
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 fromJson(o: JsonObject): Account {
|
||||
fun string(key: String) = o[key]?.jsonPrimitive?.contentOrNull
|
||||
return Account(
|
||||
serverId = string("serverId")!!,
|
||||
serverName = string("serverName").orEmpty(),
|
||||
serverUrl = string("serverUrl")!!,
|
||||
userId = UUID.fromString(string("userId")),
|
||||
userName = string("userName").orEmpty(),
|
||||
accessToken = string("token")!!,
|
||||
localUrl = string("localUrl")?.takeIf { it.isNotEmpty() && it != "null" },
|
||||
lastUsedAt = o["lastUsedAt"]?.jsonPrimitive?.longOrNull ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
internal class AccountStore(private val sessionStore: DataStore<Preferences>) {
|
||||
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
|
||||
|
||||
suspend fun read(): Saved {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
val prefs = 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 }
|
||||
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 ->
|
||||
sessionStore.edit { prefs ->
|
||||
result = change(parse(prefs))
|
||||
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
|
||||
prefs[KEY_ACCOUNTS] = JsonArray(result.map { it.toJson() }).toString()
|
||||
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
|
||||
}
|
||||
return result
|
||||
@@ -79,7 +88,7 @@ internal class AccountStore(private val context: Context) {
|
||||
|
||||
/** The single session saved before accounts existed (prototype); null when there is none. */
|
||||
suspend fun legacySession(): LegacySession? {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
val prefs = sessionStore.data.first()
|
||||
return LegacySession(
|
||||
url = prefs[LEGACY_URL] ?: return null,
|
||||
token = prefs[LEGACY_TOKEN] ?: return null,
|
||||
@@ -89,15 +98,15 @@ internal class AccountStore(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun dropLegacySession() {
|
||||
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
||||
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() }
|
||||
val array = runCatching { Json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { runCatching { Account.fromJson(it.jsonObject) }.getOrNull() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Koin: the session, login and HTTP of the shared core; the platform adds [JellyfinPlatform] and `PreferenceStores`.
|
||||
* One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting].
|
||||
*/
|
||||
val jellyfinModule = module {
|
||||
singleOf(::ServerRouting)
|
||||
single { OkHttpClient.Builder().addInterceptor(get<ServerRouting>()).build() }
|
||||
singleOf(::JellyfinSession)
|
||||
singleOf(::ServerLogin)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import org.jellyfin.sdk.JellyfinOptions
|
||||
|
||||
/** What [JellyfinSession] takes from the platform it runs on. */
|
||||
interface JellyfinPlatform {
|
||||
/** The device's name in the server's list of devices. */
|
||||
val deviceName: String
|
||||
|
||||
/** The platform's part of the SDK setup. */
|
||||
fun configure(options: JellyfinOptions.Builder) {}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -28,10 +24,10 @@ 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 ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class SessionInfo(
|
||||
val accountId: String,
|
||||
@@ -48,15 +44,15 @@ data class SessionInfo(
|
||||
* 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,
|
||||
class JellyfinSession(
|
||||
private val platform: JellyfinPlatform,
|
||||
stores: PreferenceStores,
|
||||
httpClient: OkHttpClient,
|
||||
private val routing: ServerRouting,
|
||||
) {
|
||||
private val store = AccountStore(context)
|
||||
private val store = AccountStore(stores["jellyfin_session"])
|
||||
private val jellyfin = createJellyfin {
|
||||
context = this@JellyfinSession.context
|
||||
platform.configure(this)
|
||||
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
||||
apiClientFactory = OkHttpFactory(httpClient)
|
||||
}
|
||||
@@ -102,13 +98,13 @@ class JellyfinSession @Inject constructor(
|
||||
*/
|
||||
fun refreshLocalAddress() {
|
||||
val s = _session.value ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val now = monotonicMs()
|
||||
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
||||
localCheckedAt = now
|
||||
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}")
|
||||
if (found != routing.localUrl) Log.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
||||
routing.set(s.serverUrl, found)
|
||||
mutex.withLock {
|
||||
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
|
||||
@@ -188,7 +184,7 @@ class JellyfinSession @Inject constructor(
|
||||
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
|
||||
private suspend fun revoke(client: () -> ApiClient) {
|
||||
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
|
||||
.onFailure { Timber.w(it, "Token not revoked on the server") }
|
||||
.onFailure { Log.w(it, "Token not revoked on the server") }
|
||||
}
|
||||
|
||||
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
|
||||
@@ -240,7 +236,7 @@ class JellyfinSession @Inject constructor(
|
||||
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)
|
||||
Log.i("Migrated the saved session to account %s", account.id)
|
||||
return account
|
||||
}
|
||||
|
||||
@@ -250,7 +246,7 @@ class JellyfinSession @Inject constructor(
|
||||
return id
|
||||
}
|
||||
|
||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = platform.deviceName)
|
||||
|
||||
private companion object {
|
||||
const val CLIENT_NAME = "SleepyOwl"
|
||||
@@ -2,8 +2,12 @@ package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.HttpURLConnection
|
||||
@@ -34,8 +38,8 @@ internal object LocalServerDiscovery {
|
||||
|
||||
/** 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() } }
|
||||
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) }
|
||||
runCatching { parse(get("$baseUrl/System/Info/Public")).string("Id")?.takeIf { it.isNotEmpty() } }
|
||||
.onFailure { Log.d(it, "Server id not read from %s", baseUrl) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -67,19 +71,23 @@ internal object LocalServerDiscovery {
|
||||
} 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 reply = runCatching { parse(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
val id = reply.string("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") })
|
||||
Reply(id, reply.string("Name").orEmpty(), "http://$host:$HTTP_PORT", reply.string("Address")?.trimEnd('/')?.takeIf { it.startsWith("http") })
|
||||
}
|
||||
if (id == stopAt) break
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
|
||||
}.onFailure { Log.d(it, "Jellyfin discovery failed") }
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
private fun parse(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject
|
||||
|
||||
private fun JsonObject.string(key: String): String? = get(key)?.jsonPrimitive?.contentOrNull
|
||||
|
||||
private fun get(url: String): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = TIMEOUT_MS
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -15,10 +14,9 @@ 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 ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
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. */
|
||||
@@ -51,8 +49,7 @@ class QuickConnectExpiredException : Exception("Quick Connect request expired")
|
||||
* 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) {
|
||||
class ServerLogin(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 {
|
||||
@@ -75,16 +72,16 @@ class ServerLogin @Inject constructor(private val session: JellyfinSession) {
|
||||
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()
|
||||
val started = monotonicMs()
|
||||
api.getPublicSystemInfo()
|
||||
Server(
|
||||
id = checkNotNull(info.id),
|
||||
name = info.serverName.orEmpty(),
|
||||
url = url,
|
||||
version = info.version,
|
||||
pingMs = SystemClock.elapsedRealtime() - started,
|
||||
pingMs = monotonicMs() - started,
|
||||
)
|
||||
}.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
||||
}.onFailure { Log.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4,7 +4,7 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
@@ -46,7 +46,7 @@ class ServerRouting internal constructor() : Interceptor {
|
||||
} 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)
|
||||
Log.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)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import java.io.File
|
||||
|
||||
class DesktopJellyfinPlatform : JellyfinPlatform {
|
||||
// /etc/hostname rather than InetAddress: that one may wait for DNS.
|
||||
override val deviceName = "SleepyOwl · " + (runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package ru.stenfredd.sleepyowl.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()
|
||||
}
|
||||
Reference in New Issue
Block a user