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