Data layer and baseline profile

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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