Data layer and baseline profile
This commit is contained in:
24
core/data/build.gradle.kts
Normal file
24
core/data/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.core.data"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package ru.stenfredd.movies.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
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||
import ru.stenfredd.movies.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(
|
||||
val continueWatching: List<ItemSummary>,
|
||||
val latest: List<ItemSummary>,
|
||||
val libraries: List<LibraryRow>,
|
||||
)
|
||||
|
||||
/** A library's row on home: its newest items and, once counted, how many movies and series it has. */
|
||||
@Serializable
|
||||
data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, val total: Int? = null)
|
||||
|
||||
/**
|
||||
* Home screen content.
|
||||
*
|
||||
* Shown at once from what is known: the last content of this run, else the snapshot saved on disk at the previous
|
||||
* 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,
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
private val watch: WatchRepository,
|
||||
) {
|
||||
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
||||
|
||||
private val remembered = ConcurrentHashMap<String, Remembered>()
|
||||
private val dir = File(context.filesDir, "home")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
init {
|
||||
// Watch state changed: what we have is still worth showing at once, but it must be fetched again.
|
||||
scope.launch { cache.invalidations.collect { remembered.replaceAll { _, r -> Remembered(r.content, 0) } } }
|
||||
// A signed-out account's home must not stay on the device.
|
||||
scope.launch {
|
||||
var previous = emptySet<String>()
|
||||
session.accounts.collect { list ->
|
||||
val current = list.mapTo(HashSet()) { it.id }
|
||||
(previous - current).forEach { gone ->
|
||||
remembered.remove(gone)
|
||||
File(dir, "${fileName(gone)}.json").delete()
|
||||
}
|
||||
previous = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Home content, updated again whenever watch state changes on the server (e.g. after playback). */
|
||||
fun home(): Flow<HomeContent> = flow {
|
||||
val account = session.requireSession().accountId
|
||||
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)
|
||||
cache.invalidations.collect { shown = refresh(account, shown) }
|
||||
}.flowOn(Dispatchers.Default)
|
||||
|
||||
/** With something already on screen a failed refresh only gets logged; with nothing to show it is the error. */
|
||||
private suspend fun FlowCollector<HomeContent>.refresh(account: String, shown: HomeContent?): HomeContent = try {
|
||||
fetch(account, shown)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (shown == null) throw e
|
||||
Timber.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 oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
||||
val fresh = coroutineScope {
|
||||
val continueWatching = async { watch.continueWatching() }
|
||||
val latest = async { library.latest() }
|
||||
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)
|
||||
emit(fresh)
|
||||
remember(account, fresh)
|
||||
|
||||
val counted = fresh.copy(
|
||||
libraries = coroutineScope {
|
||||
fresh.libraries.map { row ->
|
||||
async { row.copy(total = runCatching { library.count(ItemQuery(parentId = row.library.id)) }.getOrNull() ?: row.total) }
|
||||
}.awaitAll()
|
||||
},
|
||||
)
|
||||
if (counted == fresh) return fresh
|
||||
emit(counted)
|
||||
remember(account, counted)
|
||||
return counted
|
||||
}
|
||||
|
||||
private suspend fun remember(account: String, content: HomeContent) {
|
||||
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
|
||||
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") }
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
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") }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private fun fileName(account: String) = account.replace(Regex("[^A-Za-z0-9-]"), "_")
|
||||
|
||||
private companion object {
|
||||
/** Coming back to home within this time shows what was loaded without asking the server again. */
|
||||
const val FRESH_MS = 60_000L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import org.jellyfin.sdk.api.client.extensions.imageApi
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import ru.stenfredd.movies.core.data.model.ImageKind
|
||||
import ru.stenfredd.movies.core.data.model.ImageRef
|
||||
import ru.stenfredd.movies.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).
|
||||
*
|
||||
* 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) {
|
||||
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
||||
session.api.imageApi.getItemImageUrl(
|
||||
itemId = ref.itemId,
|
||||
imageType = ref.kind.toImageType(),
|
||||
fillWidth = widthPx,
|
||||
fillHeight = heightPx,
|
||||
quality = quality,
|
||||
tag = ref.tag,
|
||||
)
|
||||
|
||||
private fun ImageKind.toImageType() = when (this) {
|
||||
ImageKind.Primary -> ImageType.PRIMARY
|
||||
ImageKind.Backdrop -> ImageType.BACKDROP
|
||||
ImageKind.Thumb -> ImageType.THUMB
|
||||
ImageKind.Logo -> ImageType.LOGO
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val QUALITY = 90
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.personApi
|
||||
import org.jellyfin.sdk.api.client.extensions.showApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userViewApi
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
import ru.stenfredd.movies.core.data.SessionCache.Kind
|
||||
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||
import ru.stenfredd.movies.core.data.model.ItemSort
|
||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||
import ru.stenfredd.movies.core.data.model.Page
|
||||
import ru.stenfredd.movies.core.data.model.SearchResults
|
||||
import ru.stenfredd.movies.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(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
private val userId get() = session.requireSession().userId
|
||||
|
||||
/** Video libraries of the user, in the server's order (music, photos, live TV are left out). */
|
||||
suspend fun views(): List<LibraryView> = cache.get("views", Kind.Stable) {
|
||||
session.call { userViewApi.getUserViews(userId = userId).content.items }
|
||||
.map { it.toLibraryView() }
|
||||
.filter { it.type != LibraryType.Other }
|
||||
}
|
||||
|
||||
/**
|
||||
* Newest items, episodes grouped into their series (a series with new episodes comes as the series, or as the
|
||||
* episode itself when it is the only new one). Cheap on the server, unlike [items] sorted by date.
|
||||
*/
|
||||
suspend fun latest(parentId: UUID? = null, limit: Int = 20): List<ItemSummary> = cache.get("latest:$parentId:$limit", Kind.UserData) {
|
||||
session.call {
|
||||
libraryApi.getLatestMedia(userId = userId, parentId = parentId, limit = limit, groupItems = true, fields = CARD_FIELDS).content
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/** One page of a filtered, sorted list. The total is only counted when [withTotal] (the server pays ~0.2 s for it). */
|
||||
suspend fun items(query: ItemQuery, start: Int, limit: Int, withTotal: Boolean = true): Page<ItemSummary> =
|
||||
cache.get("items:$query:$start:$limit:$withTotal", Kind.UserData) {
|
||||
val result = session.call {
|
||||
libraryApi.getItems(
|
||||
userId = userId,
|
||||
parentId = query.parentId,
|
||||
recursive = true,
|
||||
includeItemTypes = query.kinds.mapNotNull { it.toBaseKind() },
|
||||
sortBy = query.sort.toSortBy(),
|
||||
sortOrder = listOf(if (query.descending) SortOrder.DESCENDING else SortOrder.ASCENDING),
|
||||
filters = buildList {
|
||||
if (query.unplayedOnly) add(ItemFilter.IS_UNPLAYED)
|
||||
if (query.playedOnly) add(ItemFilter.IS_PLAYED)
|
||||
if (query.favoritesOnly) add(ItemFilter.IS_FAVORITE)
|
||||
},
|
||||
genres = query.genre?.let { listOf(it) },
|
||||
personIds = query.personId?.let { listOf(it) },
|
||||
startIndex = start,
|
||||
limit = limit,
|
||||
fields = CARD_FIELDS,
|
||||
enableTotalRecordCount = withTotal,
|
||||
).content
|
||||
}
|
||||
Page(result.items.map { it.toSummary() }, start, if (withTotal) result.totalRecordCount else start + result.items.size)
|
||||
}
|
||||
|
||||
/** Number of items matching [query]; kept longer than the lists, it changes rarely. */
|
||||
suspend fun count(query: ItemQuery): Int = cache.get("count:$query", Kind.Stable) {
|
||||
session.call {
|
||||
libraryApi.getItems(
|
||||
userId = userId,
|
||||
parentId = query.parentId,
|
||||
recursive = true,
|
||||
includeItemTypes = query.kinds.mapNotNull { it.toBaseKind() },
|
||||
filters = buildList {
|
||||
if (query.unplayedOnly) add(ItemFilter.IS_UNPLAYED)
|
||||
if (query.favoritesOnly) add(ItemFilter.IS_FAVORITE)
|
||||
},
|
||||
limit = 0,
|
||||
enableTotalRecordCount = true,
|
||||
).content.totalRecordCount
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun details(id: UUID): ItemDetails = cache.get("details:$id", Kind.UserData) {
|
||||
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toDetails()
|
||||
}
|
||||
|
||||
suspend fun seasons(seriesId: UUID): List<ItemSummary> = cache.get("seasons:$seriesId", Kind.UserData) {
|
||||
session.call { showApi.getSeasons(seriesId = seriesId, userId = userId, fields = CARD_FIELDS).content.items }.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/** Episodes of one season, or of the whole series when [seasonId] is null. */
|
||||
suspend fun episodes(seriesId: UUID, seasonId: UUID? = null): List<ItemSummary> = cache.get("episodes:$seriesId:$seasonId", Kind.UserData) {
|
||||
session.call {
|
||||
showApi.getEpisodes(seriesId = seriesId, userId = userId, seasonId = seasonId, fields = FEATURED_FIELDS).content.items
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
|
||||
suspend fun similar(id: UUID, limit: Int = 12): List<ItemSummary> = cache.get("similar:$id:$limit", Kind.UserData) {
|
||||
session.call { libraryApi.getSimilarItems(itemId = id, userId = userId, limit = limit, fields = CARD_FIELDS).content.items }
|
||||
.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/** Movies, series and episodes matching [term], and people. Not cached: every keystroke is a new term. */
|
||||
suspend fun search(term: String, limit: Int = 60): SearchResults = coroutineScope {
|
||||
val items = async {
|
||||
session.call {
|
||||
libraryApi.getItems(
|
||||
userId = userId,
|
||||
searchTerm = term,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(ItemKind.Movie, ItemKind.Series, ItemKind.Episode).mapNotNull { it.toBaseKind() },
|
||||
limit = limit,
|
||||
fields = CARD_FIELDS,
|
||||
enableTotalRecordCount = false,
|
||||
).content.items
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
val people = async {
|
||||
session.call { personApi.getPersons(searchTerm = term, userId = userId, limit = PEOPLE_LIMIT).content.items }.map { it.toPerson() }
|
||||
}
|
||||
SearchResults(items.await(), people.await())
|
||||
}
|
||||
|
||||
private fun ItemSort.toSortBy(): List<ItemSortBy> = when (this) {
|
||||
ItemSort.Name -> listOf(ItemSortBy.SORT_NAME)
|
||||
ItemSort.DateAdded -> listOf(ItemSortBy.DATE_CREATED, ItemSortBy.SORT_NAME)
|
||||
ItemSort.Year -> listOf(ItemSortBy.PRODUCTION_YEAR, ItemSortBy.SORT_NAME)
|
||||
ItemSort.Rating -> listOf(ItemSortBy.COMMUNITY_RATING, ItemSortBy.SORT_NAME)
|
||||
ItemSort.DatePlayed -> listOf(ItemSortBy.DATE_PLAYED)
|
||||
ItemSort.PremiereDate -> listOf(ItemSortBy.PREMIERE_DATE, ItemSortBy.SORT_NAME)
|
||||
ItemSort.Random -> listOf(ItemSortBy.RANDOM)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PEOPLE_LIMIT = 20
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import org.jellyfin.sdk.model.api.AudioSpatialFormat
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.BaseItemPerson
|
||||
import org.jellyfin.sdk.model.api.CollectionType
|
||||
import org.jellyfin.sdk.model.api.ImageType
|
||||
import org.jellyfin.sdk.model.api.ItemFields
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.movies.core.data.model.AudioTrack
|
||||
import ru.stenfredd.movies.core.data.model.Chapter
|
||||
import ru.stenfredd.movies.core.data.model.ImageKind
|
||||
import ru.stenfredd.movies.core.data.model.ImageRef
|
||||
import ru.stenfredd.movies.core.data.model.ItemDetails
|
||||
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||
import ru.stenfredd.movies.core.data.model.LibraryType
|
||||
import ru.stenfredd.movies.core.data.model.LibraryView
|
||||
import ru.stenfredd.movies.core.data.model.MediaInfo
|
||||
import ru.stenfredd.movies.core.data.model.Person
|
||||
import ru.stenfredd.movies.core.data.model.PersonKind
|
||||
import ru.stenfredd.movies.core.data.model.SubtitleTrack
|
||||
import ru.stenfredd.movies.core.data.model.UserState
|
||||
import ru.stenfredd.movies.core.data.model.VideoRange
|
||||
import ru.stenfredd.movies.core.data.model.VideoTrack
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import java.util.UUID
|
||||
|
||||
/** Fields every list asks for on top of the defaults: the format badge, "new" and episode counts. */
|
||||
internal val CARD_FIELDS = listOf(ItemFields.WIDTH, ItemFields.DATE_CREATED, ItemFields.CHILD_COUNT)
|
||||
|
||||
/** Lists whose first card is shown big (continue watching) also need the description and genres. */
|
||||
internal val FEATURED_FIELDS = CARD_FIELDS + listOf(ItemFields.OVERVIEW, ItemFields.GENRES)
|
||||
|
||||
internal val DETAIL_FIELDS = FEATURED_FIELDS + listOf(
|
||||
ItemFields.ORIGINAL_TITLE, ItemFields.TAGLINES, ItemFields.STUDIOS, ItemFields.PEOPLE, ItemFields.MEDIA_SOURCES,
|
||||
ItemFields.MEDIA_STREAMS, ItemFields.CHAPTERS,
|
||||
)
|
||||
|
||||
private const val TICKS_PER_MS = 10_000L
|
||||
|
||||
internal fun BaseItemDto.toSummary(): ItemSummary {
|
||||
val kind = type.toKind()
|
||||
val primaryTag = imageTags?.get(ImageType.PRIMARY)
|
||||
return ItemSummary(
|
||||
id = id,
|
||||
kind = kind,
|
||||
name = name.orEmpty(),
|
||||
year = productionYear,
|
||||
communityRating = communityRating,
|
||||
officialRating = officialRating,
|
||||
runtimeMs = runTimeTicks?.div(TICKS_PER_MS),
|
||||
overview = overview,
|
||||
genres = genres.orEmpty(),
|
||||
videoWidth = width,
|
||||
dateAddedAt = dateCreated?.epochMs(),
|
||||
premiereAt = premiereDate?.epochMs(),
|
||||
seriesId = seriesId,
|
||||
seriesName = seriesName,
|
||||
seasonId = seasonId,
|
||||
seasonNumber = if (kind == ItemKind.Season) indexNumber else parentIndexNumber,
|
||||
episodeNumber = if (kind == ItemKind.Episode) indexNumber else null,
|
||||
episodeNumberEnd = if (kind == ItemKind.Episode) indexNumberEnd else null,
|
||||
childCount = childCount,
|
||||
user = userData?.let { u ->
|
||||
UserState(
|
||||
played = u.played,
|
||||
favorite = u.isFavorite,
|
||||
progress = u.playedPercentage?.takeIf { it > 0 && kind != ItemKind.Series && kind != ItemKind.Season }?.let { (it / 100).toFloat() },
|
||||
positionMs = u.playbackPositionTicks / TICKS_PER_MS,
|
||||
unplayedCount = u.unplayedItemCount,
|
||||
lastPlayedAt = u.lastPlayedDate?.epochMs(),
|
||||
)
|
||||
} ?: UserState(),
|
||||
primary = primaryTag?.let { image(id, ImageKind.Primary, it) },
|
||||
backdrop = backdropImageTags?.firstOrNull()?.let { image(id, ImageKind.Backdrop, it) }
|
||||
?: parentBackdropItemId?.let { pid -> parentBackdropImageTags?.firstOrNull()?.let { image(pid, ImageKind.Backdrop, it) } },
|
||||
thumb = imageTags?.get(ImageType.THUMB)?.let { image(id, ImageKind.Thumb, it) }
|
||||
?: parentThumbItemId?.let { pid -> parentThumbImageTag?.let { image(pid, ImageKind.Thumb, it) } }
|
||||
?: seriesId?.let { sid -> seriesThumbImageTag?.let { image(sid, ImageKind.Thumb, it) } },
|
||||
seriesPrimary = seriesId?.let { sid -> seriesPrimaryImageTag?.let { image(sid, ImageKind.Primary, it) } }
|
||||
?: if (kind == ItemKind.Season) parentPrimaryImageItemId?.let { pid -> parentPrimaryImageTag?.let { image(pid, ImageKind.Primary, it) } } else null,
|
||||
logo = imageTags?.get(ImageType.LOGO)?.let { image(id, ImageKind.Logo, it) }
|
||||
?: parentLogoItemId?.let { pid -> parentLogoImageTag?.let { image(pid, ImageKind.Logo, it) } },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun BaseItemDto.toDetails(): ItemDetails = ItemDetails(
|
||||
summary = toSummary(),
|
||||
originalTitle = originalTitle?.takeIf { it != name },
|
||||
tagline = taglines?.firstOrNull(),
|
||||
studios = studios.orEmpty().mapNotNull { it.name },
|
||||
people = people.orEmpty().map { it.toPerson() },
|
||||
media = mediaSources?.firstOrNull()?.toMediaInfo(),
|
||||
chapters = chapters.orEmpty().map { Chapter(it.name.orEmpty(), it.startPositionTicks / TICKS_PER_MS) },
|
||||
)
|
||||
|
||||
internal fun BaseItemDto.toLibraryView(): LibraryView = LibraryView(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
type = when (collectionType) {
|
||||
CollectionType.MOVIES -> LibraryType.Movies
|
||||
CollectionType.TVSHOWS -> LibraryType.Shows
|
||||
null -> LibraryType.Mixed
|
||||
else -> LibraryType.Other
|
||||
},
|
||||
image = imageTags?.get(ImageType.PRIMARY)?.let { image(id, ImageKind.Primary, it) },
|
||||
)
|
||||
|
||||
internal fun BaseItemPerson.toPerson() = Person(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
role = role?.takeIf { it.isNotBlank() },
|
||||
kind = when (type.serialName) {
|
||||
"Actor", "GuestStar" -> PersonKind.Actor
|
||||
"Director" -> PersonKind.Director
|
||||
"Writer" -> PersonKind.Writer
|
||||
"Producer" -> PersonKind.Producer
|
||||
else -> PersonKind.Other
|
||||
},
|
||||
image = primaryImageTag?.let { ImageRef(id, ImageKind.Primary, it, imageBlurHashes?.get(ImageType.PRIMARY)?.get(it)) },
|
||||
)
|
||||
|
||||
internal fun BaseItemDto.toPerson() = Person(
|
||||
id = id,
|
||||
name = name.orEmpty(),
|
||||
role = null,
|
||||
kind = PersonKind.Other,
|
||||
image = imageTags?.get(ImageType.PRIMARY)?.let { image(id, ImageKind.Primary, it) },
|
||||
)
|
||||
|
||||
private fun MediaSourceInfo.toMediaInfo(): MediaInfo {
|
||||
val streams = mediaStreams.orEmpty()
|
||||
return MediaInfo(
|
||||
sourceId = id.orEmpty(),
|
||||
container = container,
|
||||
sizeBytes = size,
|
||||
bitrate = bitrate,
|
||||
video = streams.firstOrNull { it.type == MediaStreamType.VIDEO }?.toVideo(),
|
||||
audio = streams.filter { it.type == MediaStreamType.AUDIO }.map { it.toAudio(defaultAudioStreamIndex) },
|
||||
subtitles = streams.filter { it.type == MediaStreamType.SUBTITLE }.map { it.toSubtitle(defaultSubtitleStreamIndex) },
|
||||
defaultAudioIndex = defaultAudioStreamIndex,
|
||||
defaultSubtitleIndex = defaultSubtitleStreamIndex,
|
||||
)
|
||||
}
|
||||
|
||||
private fun MediaStream.toVideo() = VideoTrack(
|
||||
codec = codec,
|
||||
width = width,
|
||||
height = height,
|
||||
range = videoRangeType.toRange(),
|
||||
dvProfile = dvProfile,
|
||||
bitDepth = bitDepth,
|
||||
frameRate = realFrameRate ?: averageFrameRate,
|
||||
)
|
||||
|
||||
private fun VideoRangeType.toRange(): VideoRange = when {
|
||||
this == VideoRangeType.SDR -> VideoRange.SDR
|
||||
this == VideoRangeType.HLG -> VideoRange.HLG
|
||||
serialName.startsWith("DOVI") -> VideoRange.DolbyVision
|
||||
serialName == "HDR10Plus" -> VideoRange.HDR10Plus
|
||||
serialName == "HDR10" -> VideoRange.HDR10
|
||||
else -> VideoRange.Other
|
||||
}
|
||||
|
||||
private fun MediaStream.toAudio(defaultIndex: Int?) = AudioTrack(
|
||||
index = index,
|
||||
title = displayTitle ?: title ?: codec.orEmpty(),
|
||||
language = language,
|
||||
codec = codec,
|
||||
channels = channels,
|
||||
atmos = audioSpatialFormat == AudioSpatialFormat.DOLBY_ATMOS || profile?.contains("Atmos", ignoreCase = true) == true,
|
||||
isDefault = index == defaultIndex,
|
||||
)
|
||||
|
||||
private fun MediaStream.toSubtitle(defaultIndex: Int?) = SubtitleTrack(
|
||||
index = index,
|
||||
title = displayTitle ?: title ?: codec.orEmpty(),
|
||||
language = language,
|
||||
codec = codec,
|
||||
external = isExternal,
|
||||
forced = isForced,
|
||||
isDefault = index == defaultIndex,
|
||||
)
|
||||
|
||||
private fun BaseItemDto.image(itemId: UUID, kind: ImageKind, tag: String): ImageRef {
|
||||
val type = when (kind) {
|
||||
ImageKind.Primary -> ImageType.PRIMARY
|
||||
ImageKind.Backdrop -> ImageType.BACKDROP
|
||||
ImageKind.Thumb -> ImageType.THUMB
|
||||
ImageKind.Logo -> ImageType.LOGO
|
||||
}
|
||||
return ImageRef(itemId, kind, tag, imageBlurHashes?.get(type)?.get(tag))
|
||||
}
|
||||
|
||||
internal fun BaseItemKind.toKind(): ItemKind = when (this) {
|
||||
BaseItemKind.MOVIE -> ItemKind.Movie
|
||||
BaseItemKind.SERIES -> ItemKind.Series
|
||||
BaseItemKind.SEASON -> ItemKind.Season
|
||||
BaseItemKind.EPISODE -> ItemKind.Episode
|
||||
BaseItemKind.BOX_SET -> ItemKind.BoxSet
|
||||
BaseItemKind.FOLDER, BaseItemKind.COLLECTION_FOLDER, BaseItemKind.USER_VIEW -> ItemKind.Folder
|
||||
BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO -> ItemKind.Video
|
||||
else -> ItemKind.Other
|
||||
}
|
||||
|
||||
internal fun ItemKind.toBaseKind(): BaseItemKind? = when (this) {
|
||||
ItemKind.Movie -> BaseItemKind.MOVIE
|
||||
ItemKind.Series -> BaseItemKind.SERIES
|
||||
ItemKind.Season -> BaseItemKind.SEASON
|
||||
ItemKind.Episode -> BaseItemKind.EPISODE
|
||||
ItemKind.BoxSet -> BaseItemKind.BOX_SET
|
||||
ItemKind.Folder -> BaseItemKind.FOLDER
|
||||
ItemKind.Video -> BaseItemKind.VIDEO
|
||||
ItemKind.Other -> null
|
||||
}
|
||||
|
||||
/** The SDK converts server dates to the device's time zone. */
|
||||
private fun LocalDateTime.epochMs(): Long = atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()
|
||||
@@ -0,0 +1,95 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.movies.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
|
||||
* for the network again. Identical requests in flight share one call.
|
||||
*
|
||||
* Everything that depends on watch state is dropped when the server reports a change ([JellyfinSession.userDataChanges]:
|
||||
* 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) {
|
||||
enum class Kind(val ttlMs: Long) {
|
||||
/** Lists with watch state: resume, next up, latest, details. */
|
||||
UserData(10 * 60_000L),
|
||||
/** Libraries, item counts. */
|
||||
Stable(30 * 60_000L),
|
||||
}
|
||||
|
||||
private class Entry(val kind: Kind, val createdAt: Long, val value: CompletableDeferred<Any?>)
|
||||
|
||||
private val entries = ConcurrentHashMap<String, Entry>()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val _invalidations = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
|
||||
/** Emits after user-data entries were dropped: screens showing such data should reload. */
|
||||
val invalidations: SharedFlow<Unit> = _invalidations.asSharedFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
session.userDataChanges.collect {
|
||||
drop(Kind.UserData)
|
||||
_invalidations.tryEmit(Unit)
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
session.session.distinctUntilChangedBy { it?.accountId }.drop(1).collect { entries.clear() }
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
suspend fun <T> get(key: String, kind: Kind, load: suspend () -> T): T {
|
||||
// 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 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) {
|
||||
try {
|
||||
return entry.value.await() as T
|
||||
} catch (e: CancellationException) {
|
||||
// The caller that was loading it went away; load it ourselves unless we are cancelled too.
|
||||
currentCoroutineContext().ensureActive()
|
||||
continue
|
||||
}
|
||||
}
|
||||
return try {
|
||||
// Off the main thread: turning DTOs into models of a big response takes a few ms.
|
||||
withContext(Dispatchers.Default) { load() }.also { mine.value.complete(it) }
|
||||
} catch (e: Throwable) {
|
||||
entries.remove(scoped, mine)
|
||||
mine.value.completeExceptionally(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.showApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userDataApi
|
||||
import org.jellyfin.sdk.model.api.MediaType
|
||||
import ru.stenfredd.movies.core.data.SessionCache.Kind
|
||||
import ru.stenfredd.movies.core.data.model.ItemKind
|
||||
import ru.stenfredd.movies.core.data.model.ItemQuery
|
||||
import ru.stenfredd.movies.core.data.model.ItemSort
|
||||
import ru.stenfredd.movies.core.data.model.ItemSummary
|
||||
import ru.stenfredd.movies.core.data.model.Page
|
||||
import ru.stenfredd.movies.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(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
) {
|
||||
private val userId get() = session.requireSession().userId
|
||||
|
||||
/** Started movies and episodes, most recently watched first. */
|
||||
suspend fun resume(limit: Int = 20): List<ItemSummary> = cache.get("resume:$limit", Kind.UserData) {
|
||||
session.call {
|
||||
libraryApi.getResumeItems(userId = userId, limit = limit, mediaTypes = listOf(MediaType.VIDEO), fields = FEATURED_FIELDS).content.items
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/** The next unwatched episode of each series in progress. */
|
||||
suspend fun nextUp(limit: Int = 20): List<ItemSummary> = cache.get("nextup:$limit", Kind.UserData) {
|
||||
session.call {
|
||||
showApi.getNextUp(userId = userId, limit = limit, fields = FEATURED_FIELDS, enableResumable = false, enableRewatching = false).content.items
|
||||
}.map { it.toSummary() }
|
||||
}
|
||||
|
||||
/**
|
||||
* "Continue watching": started items first (most recent first), then the next episode of each series in progress
|
||||
* that isn't already there.
|
||||
*/
|
||||
suspend fun continueWatching(limit: Int = 20): List<ItemSummary> = coroutineScope {
|
||||
val resume = async { resume(limit) }
|
||||
val nextUp = async { nextUp(limit) }
|
||||
val started = resume.await()
|
||||
val seriesStarted = started.mapNotNullTo(HashSet()) { it.seriesId }
|
||||
(started + nextUp.await().filter { it.seriesId !in seriesStarted && started.none { s -> s.id == it.id } }).take(limit)
|
||||
}
|
||||
|
||||
/** Watched movies and episodes, most recent first. */
|
||||
suspend fun history(start: Int, limit: Int): Page<ItemSummary> = library.items(
|
||||
ItemQuery(kinds = setOf(ItemKind.Movie, ItemKind.Episode), sort = ItemSort.DatePlayed, descending = true, playedOnly = true),
|
||||
start,
|
||||
limit,
|
||||
)
|
||||
|
||||
suspend fun favorites(start: Int, limit: Int, kinds: Set<ItemKind> = setOf(ItemKind.Movie, ItemKind.Series)): Page<ItemSummary> =
|
||||
library.items(ItemQuery(kinds = kinds, favoritesOnly = true, sort = ItemSort.Name), start, limit)
|
||||
|
||||
suspend fun setPlayed(id: UUID, played: Boolean) {
|
||||
session.call { if (played) userDataApi.markPlayedItem(itemId = id, userId = userId) else userDataApi.markUnplayedItem(itemId = id, userId = userId) }
|
||||
session.notifyUserDataChanged(id)
|
||||
}
|
||||
|
||||
suspend fun setFavorite(id: UUID, favorite: Boolean) {
|
||||
session.call { if (favorite) userDataApi.markFavoriteItem(itemId = id, userId = userId) else userDataApi.unmarkFavoriteItem(itemId = id, userId = userId) }
|
||||
session.notifyUserDataChanged(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
@file:UseSerializers(UUIDSerializer::class)
|
||||
|
||||
package ru.stenfredd.movies.core.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.UseSerializers
|
||||
import org.jellyfin.sdk.model.serializer.UUIDSerializer
|
||||
import java.util.UUID
|
||||
|
||||
/*
|
||||
* App-side models of server data. Screens and view models work with these, never with SDK DTOs: they hold only what
|
||||
* the app shows, are immutable (the UI module marks this package stable for Compose) and serializable (the home
|
||||
* snapshot on disk).
|
||||
*/
|
||||
|
||||
enum class ItemKind { Movie, Series, Season, Episode, BoxSet, Folder, Video, Other }
|
||||
|
||||
enum class ImageKind { Primary, Backdrop, Thumb, Logo }
|
||||
|
||||
/** One server image; the URL with the needed size comes from `ImageUrls`. */
|
||||
@Serializable
|
||||
data class ImageRef(val itemId: UUID, val kind: ImageKind, val tag: String, val blurHash: String? = null)
|
||||
|
||||
/** Per-user state of an item. */
|
||||
@Serializable
|
||||
data class UserState(
|
||||
val played: Boolean = false,
|
||||
val favorite: Boolean = false,
|
||||
/** 0..1 for a started item, else null. */
|
||||
val progress: Float? = null,
|
||||
val positionMs: Long = 0,
|
||||
/** Unwatched episodes of a series or season. */
|
||||
val unplayedCount: Int? = null,
|
||||
val lastPlayedAt: Long? = null,
|
||||
)
|
||||
|
||||
/** An item as cards and lists show it: movie, series, season or episode. */
|
||||
@Serializable
|
||||
data class ItemSummary(
|
||||
val id: UUID,
|
||||
val kind: ItemKind,
|
||||
val name: String,
|
||||
val year: Int? = null,
|
||||
val communityRating: Float? = null,
|
||||
val officialRating: String? = null,
|
||||
val runtimeMs: Long? = null,
|
||||
val overview: String? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
/** Width of the main video stream, px: the "4K" badge. */
|
||||
val videoWidth: Int? = null,
|
||||
val dateAddedAt: Long? = null,
|
||||
val premiereAt: Long? = null,
|
||||
val seriesId: UUID? = null,
|
||||
val seriesName: String? = null,
|
||||
val seasonId: UUID? = null,
|
||||
/** Season number of an episode, or of the season itself. */
|
||||
val seasonNumber: Int? = null,
|
||||
val episodeNumber: Int? = null,
|
||||
val episodeNumberEnd: Int? = null,
|
||||
val childCount: Int? = null,
|
||||
val user: UserState = UserState(),
|
||||
/** Poster (movie, series, season) or still (episode). */
|
||||
val primary: ImageRef? = null,
|
||||
/** The item's own backdrop, else its parent's (series for an episode). */
|
||||
val backdrop: ImageRef? = null,
|
||||
/** Landscape thumb of the item or its series. */
|
||||
val thumb: ImageRef? = null,
|
||||
/** Poster of the series an episode or season belongs to. */
|
||||
val seriesPrimary: ImageRef? = null,
|
||||
val logo: ImageRef? = null,
|
||||
) {
|
||||
val isPlayable: Boolean get() = kind == ItemKind.Movie || kind == ItemKind.Episode || kind == ItemKind.Video
|
||||
}
|
||||
|
||||
/** A user view (library) on the server. */
|
||||
@Serializable
|
||||
data class LibraryView(val id: UUID, val name: String, val type: LibraryType, val image: ImageRef? = null)
|
||||
|
||||
enum class LibraryType { Movies, Shows, Mixed, Other }
|
||||
|
||||
@Serializable
|
||||
data class Person(val id: UUID, val name: String, val role: String?, val kind: PersonKind, val image: ImageRef?)
|
||||
|
||||
enum class PersonKind { Actor, Director, Writer, Producer, Other }
|
||||
|
||||
/** One page of a long list: [items] start at [start] of [total]. */
|
||||
data class Page<T>(val items: List<T>, val start: Int, val total: Int) {
|
||||
val end: Int get() = start + items.size
|
||||
}
|
||||
|
||||
enum class ItemSort { Name, DateAdded, Year, Rating, DatePlayed, PremiereDate, Random }
|
||||
|
||||
/** Filter and order of an item list (library grid, favourites, history). */
|
||||
data class ItemQuery(
|
||||
val parentId: UUID? = null,
|
||||
val kinds: Set<ItemKind> = setOf(ItemKind.Movie, ItemKind.Series),
|
||||
val sort: ItemSort = ItemSort.Name,
|
||||
val descending: Boolean = false,
|
||||
val unplayedOnly: Boolean = false,
|
||||
val playedOnly: Boolean = false,
|
||||
val favoritesOnly: Boolean = false,
|
||||
val genre: String? = null,
|
||||
val personId: UUID? = null,
|
||||
)
|
||||
|
||||
/** Everything the movie/episode/series screens show about one item. */
|
||||
data class ItemDetails(
|
||||
val summary: ItemSummary,
|
||||
val originalTitle: String?,
|
||||
val tagline: String?,
|
||||
val studios: List<String>,
|
||||
val people: List<Person>,
|
||||
val media: MediaInfo?,
|
||||
val chapters: List<Chapter>,
|
||||
)
|
||||
|
||||
data class Chapter(val name: String, val startMs: Long)
|
||||
|
||||
/** The file of a playable item: what the format badges and the "file" line show, and the track choices. */
|
||||
data class MediaInfo(
|
||||
val sourceId: String,
|
||||
val container: String?,
|
||||
val sizeBytes: Long?,
|
||||
val bitrate: Int?,
|
||||
val video: VideoTrack?,
|
||||
val audio: List<AudioTrack>,
|
||||
val subtitles: List<SubtitleTrack>,
|
||||
val defaultAudioIndex: Int?,
|
||||
val defaultSubtitleIndex: Int?,
|
||||
)
|
||||
|
||||
data class VideoTrack(
|
||||
val codec: String?,
|
||||
val width: Int?,
|
||||
val height: Int?,
|
||||
val range: VideoRange,
|
||||
val dvProfile: Int?,
|
||||
val bitDepth: Int?,
|
||||
val frameRate: Float?,
|
||||
)
|
||||
|
||||
enum class VideoRange { SDR, HDR10, HDR10Plus, HLG, DolbyVision, Other }
|
||||
|
||||
data class AudioTrack(
|
||||
val index: Int,
|
||||
val title: String,
|
||||
val language: String?,
|
||||
val codec: String?,
|
||||
val channels: Int?,
|
||||
/** Dolby Atmos (TrueHD or E-AC-3 JOC). */
|
||||
val atmos: Boolean,
|
||||
val isDefault: Boolean,
|
||||
)
|
||||
|
||||
data class SubtitleTrack(
|
||||
val index: Int,
|
||||
val title: String,
|
||||
val language: String?,
|
||||
val codec: String?,
|
||||
val external: Boolean,
|
||||
val forced: Boolean,
|
||||
val isDefault: Boolean,
|
||||
)
|
||||
|
||||
/** Search results: items (grouped by kind on screen) and people. */
|
||||
data class SearchResults(val items: List<ItemSummary>, val people: List<Person>)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user