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>)
|
||||
Reference in New Issue
Block a user