Search, favourites, person page and settings
This commit is contained in:
@@ -18,6 +18,7 @@ android {
|
||||
dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
|
||||
@@ -6,6 +6,7 @@ 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.BaseItemKind
|
||||
import org.jellyfin.sdk.model.api.ItemFilter
|
||||
import org.jellyfin.sdk.model.api.ItemSortBy
|
||||
import org.jellyfin.sdk.model.api.SortOrder
|
||||
@@ -18,7 +19,8 @@ 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.data.model.Person
|
||||
import ru.stenfredd.movies.core.data.model.itemKinds
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -67,6 +69,7 @@ class LibraryRepository @Inject constructor(
|
||||
},
|
||||
genres = query.genre?.let { listOf(it) },
|
||||
personIds = query.personId?.let { listOf(it) },
|
||||
searchTerm = query.searchTerm,
|
||||
startIndex = start,
|
||||
limit = limit,
|
||||
fields = CARD_FIELDS,
|
||||
@@ -114,25 +117,52 @@ class LibraryRepository @Inject constructor(
|
||||
.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())
|
||||
/**
|
||||
* Movies or series of library [view] matching [term], best matches first. Per library and of its own type:
|
||||
* the screen groups results by library, and mixed types are much slower on the server. Not cached: every
|
||||
* keystroke is a new term.
|
||||
*/
|
||||
suspend fun search(term: String, view: LibraryView, limit: Int = SEARCH_LIMIT): List<ItemSummary> = session.call {
|
||||
libraryApi.getItems(
|
||||
userId = userId,
|
||||
parentId = view.id,
|
||||
searchTerm = term,
|
||||
recursive = true,
|
||||
includeItemTypes = view.itemKinds.mapNotNull { it.toBaseKind() },
|
||||
limit = limit,
|
||||
fields = CARD_FIELDS,
|
||||
enableTotalRecordCount = false,
|
||||
).content.items
|
||||
}.map { it.toSummary() }
|
||||
|
||||
/**
|
||||
* People matching [term]. Through `/Items`, not `/Persons`: the latter finds nothing by a Cyrillic name
|
||||
* on Jellyfin 12 («питт» → 0, while `/Items` finds «Брэд Питт»).
|
||||
*/
|
||||
suspend fun searchPeople(term: String, limit: Int = PEOPLE_LIMIT): List<Person> = session.call {
|
||||
libraryApi.getItems(
|
||||
userId = userId,
|
||||
searchTerm = term,
|
||||
recursive = true,
|
||||
includeItemTypes = listOf(BaseItemKind.PERSON),
|
||||
limit = limit,
|
||||
enableTotalRecordCount = false,
|
||||
).content.items
|
||||
}.map { it.toPerson() }
|
||||
|
||||
suspend fun person(id: UUID): Person = cache.get("person:$id", Kind.Stable) {
|
||||
session.call { libraryApi.getItem(itemId = id, userId = userId).content }.toPerson()
|
||||
}
|
||||
|
||||
/**
|
||||
* Movies, series and single episodes (guest roles) with person [id], newest first; one request per type
|
||||
* (mixed types are slow).
|
||||
*/
|
||||
suspend fun filmography(id: UUID): List<ItemSummary> = coroutineScope {
|
||||
listOf(ItemKind.Movie, ItemKind.Series, ItemKind.Episode)
|
||||
.map { kind -> async { items(ItemQuery(kinds = setOf(kind), personId = id, sort = ItemSort.PremiereDate, descending = true), 0, FILMOGRAPHY_LIMIT, withTotal = false).items } }
|
||||
.flatMap { it.await() }
|
||||
.sortedWith(compareByDescending<ItemSummary> { it.year ?: 0 }.thenByDescending { it.premiereAt ?: 0L }.thenBy { it.name })
|
||||
}
|
||||
|
||||
private fun ItemSort.toSortBy(): List<ItemSortBy> = when (this) {
|
||||
@@ -147,5 +177,7 @@ class LibraryRepository @Inject constructor(
|
||||
|
||||
private companion object {
|
||||
const val PEOPLE_LIMIT = 20
|
||||
const val SEARCH_LIMIT = 40
|
||||
const val FILMOGRAPHY_LIMIT = 200
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.searchStore by preferencesDataStore("search")
|
||||
|
||||
/** Recent search queries, kept on the device per account, newest first. */
|
||||
@Singleton
|
||||
class SearchHistory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
|
||||
|
||||
fun recent(): Flow<List<String>> {
|
||||
val key = key()
|
||||
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
}
|
||||
|
||||
/** Puts [query] first; the same query typed in another case replaces the old one. */
|
||||
suspend fun add(query: String) {
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return
|
||||
val key = key()
|
||||
context.searchStore.edit { prefs ->
|
||||
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
|
||||
prefs[key] = JSONArray(list.take(LIMIT)).toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(raw: String): List<String> = runCatching {
|
||||
val array = JSONArray(raw)
|
||||
List(array.length()) { array.getString(it) }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private companion object {
|
||||
const val LIMIT = 8
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,12 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops what depends on the user's data or settings (default tracks in details) and tells screens to reload. */
|
||||
fun dropUserData() {
|
||||
drop(Kind.UserData)
|
||||
_invalidations.tryEmit(Unit)
|
||||
}
|
||||
|
||||
private fun drop(kind: Kind) {
|
||||
val before = entries.size
|
||||
entries.entries.removeAll { it.value.kind == kind }
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package ru.stenfredd.movies.core.data
|
||||
|
||||
import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
|
||||
* uses them): the server applies them when it picks a file's default tracks, which the movie page and the player start with.
|
||||
*/
|
||||
data class LanguageSettings(
|
||||
/** ISO 639-2 ("rus", "eng"), null = the file's default track. */
|
||||
val audio: String?,
|
||||
val subtitleMode: SubtitleMode,
|
||||
/** ISO 639-2, null = any language. */
|
||||
val subtitleLanguage: String?,
|
||||
)
|
||||
|
||||
enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
|
||||
|
||||
/** Server name, version and address for the settings screen. */
|
||||
data class ServerInfo(val name: String, val version: String?)
|
||||
|
||||
@Singleton
|
||||
class UserSettingsRepository @Inject constructor(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
suspend fun languages(): LanguageSettings = configuration().toLanguages()
|
||||
|
||||
/**
|
||||
* Saves [settings] on the server; cached details (with the default tracks picked by the old settings) are dropped.
|
||||
* Only what changed is written: other apps may keep values this screen doesn't show (a language with "default track").
|
||||
*/
|
||||
suspend fun setLanguages(settings: LanguageSettings) {
|
||||
val userId = session.requireSession().userId
|
||||
val old = configuration()
|
||||
val shown = old.toLanguages()
|
||||
var config = old
|
||||
if (settings.audio != shown.audio) {
|
||||
config = config.copy(
|
||||
audioLanguagePreference = settings.audio.orEmpty(),
|
||||
// With this on the server takes the file's default track whatever the language.
|
||||
playDefaultAudioTrack = settings.audio == null,
|
||||
)
|
||||
}
|
||||
if (settings.subtitleMode != shown.subtitleMode) config = config.copy(subtitleMode = settings.subtitleMode.toApi())
|
||||
if (settings.subtitleLanguage != shown.subtitleLanguage) config = config.copy(subtitleLanguagePreference = settings.subtitleLanguage.orEmpty())
|
||||
if (config == old) return
|
||||
session.call { userApi.updateUserConfiguration(userId, config) }
|
||||
cache.dropUserData()
|
||||
}
|
||||
|
||||
suspend fun server(): ServerInfo {
|
||||
val info = cache.get("server", SessionCache.Kind.Stable) { session.call { systemApi.getPublicSystemInfo().content } }
|
||||
return ServerInfo(info.serverName.orEmpty(), info.version)
|
||||
}
|
||||
|
||||
private fun UserConfiguration.toLanguages() = LanguageSettings(
|
||||
audio = audioLanguagePreference?.takeIf { it.isNotBlank() && !playDefaultAudioTrack },
|
||||
subtitleMode = subtitleMode.toMode(),
|
||||
subtitleLanguage = subtitleLanguagePreference?.takeIf { it.isNotBlank() },
|
||||
)
|
||||
|
||||
private suspend fun configuration(): UserConfiguration =
|
||||
checkNotNull(session.call { userApi.getCurrentUser().content }.configuration) { "Сервер не вернул настройки пользователя" }
|
||||
|
||||
private fun SubtitlePlaybackMode.toMode() = when (this) {
|
||||
SubtitlePlaybackMode.ALWAYS -> SubtitleMode.Always
|
||||
SubtitlePlaybackMode.ONLY_FORCED -> SubtitleMode.OnlyForced
|
||||
SubtitlePlaybackMode.SMART -> SubtitleMode.Smart
|
||||
SubtitlePlaybackMode.NONE -> SubtitleMode.None
|
||||
SubtitlePlaybackMode.DEFAULT -> SubtitleMode.Default
|
||||
}
|
||||
|
||||
private fun SubtitleMode.toApi() = when (this) {
|
||||
SubtitleMode.Always -> SubtitlePlaybackMode.ALWAYS
|
||||
SubtitleMode.OnlyForced -> SubtitlePlaybackMode.ONLY_FORCED
|
||||
SubtitleMode.Smart -> SubtitlePlaybackMode.SMART
|
||||
SubtitleMode.None -> SubtitlePlaybackMode.NONE
|
||||
SubtitleMode.Default -> SubtitlePlaybackMode.DEFAULT
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ 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.LibraryView
|
||||
import ru.stenfredd.movies.core.data.model.Page
|
||||
import ru.stenfredd.movies.core.data.model.itemKinds
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
@@ -60,8 +62,13 @@ class WatchRepository @Inject constructor(
|
||||
withTotal = false,
|
||||
)
|
||||
|
||||
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)
|
||||
/** Favourite movies or series of library [view], by name. All of them: favourites are a short list. */
|
||||
suspend fun favorites(view: LibraryView): List<ItemSummary> = library.items(
|
||||
ItemQuery(parentId = view.id, kinds = view.itemKinds, favoritesOnly = true, sort = ItemSort.Name),
|
||||
0,
|
||||
FAVORITES_LIMIT,
|
||||
withTotal = false,
|
||||
).items
|
||||
|
||||
suspend fun setPlayed(id: UUID, played: Boolean) {
|
||||
session.call { if (played) userDataApi.markPlayedItem(itemId = id, userId = userId) else userDataApi.markUnplayedItem(itemId = id, userId = userId) }
|
||||
@@ -72,4 +79,8 @@ class WatchRepository @Inject constructor(
|
||||
session.call { if (favorite) userDataApi.markFavoriteItem(itemId = id, userId = userId) else userDataApi.unmarkFavoriteItem(itemId = id, userId = userId) }
|
||||
session.notifyUserDataChanged(id)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val FAVORITES_LIMIT = 500
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ data class ItemQuery(
|
||||
val favoritesOnly: Boolean = false,
|
||||
val genre: String? = null,
|
||||
val personId: UUID? = null,
|
||||
val searchTerm: String? = null,
|
||||
)
|
||||
|
||||
/** Everything the movie/episode/series screens show about one item. */
|
||||
@@ -179,6 +180,3 @@ data class SubtitleTrack(
|
||||
val forced: Boolean,
|
||||
val isDefault: Boolean,
|
||||
)
|
||||
|
||||
/** Search results: items (grouped by kind on screen) and people. */
|
||||
data class SearchResults(val items: List<ItemSummary>, val people: List<Person>)
|
||||
|
||||
@@ -17,9 +17,11 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jellyfin.sdk.api.client.ApiClient
|
||||
import org.jellyfin.sdk.api.client.HttpClientOptions
|
||||
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
import org.jellyfin.sdk.api.okhttp.OkHttpFactory
|
||||
import org.jellyfin.sdk.createJellyfin
|
||||
import org.jellyfin.sdk.discovery.DiscoveryService
|
||||
@@ -159,15 +161,31 @@ class JellyfinSession @Inject constructor(
|
||||
activate(account)
|
||||
}
|
||||
|
||||
/** Signs out of the current account and forgets it on this device. */
|
||||
/** Signs out of the current account (its token is revoked on the server, if it answers) and forgets it on this device. */
|
||||
suspend fun logout() = mutex.withLock {
|
||||
val current = _session.value ?: return@withLock
|
||||
revoke { api }
|
||||
_accounts.value = store.update(currentId = null) { list -> list.filterNot { it.id == current.accountId } }
|
||||
api.update(accessToken = null)
|
||||
routing.set(null, null)
|
||||
_session.value = null
|
||||
}
|
||||
|
||||
/** Forgets another saved account on this device; its token is revoked on the server, if it answers. */
|
||||
suspend fun forget(accountId: String) = mutex.withLock {
|
||||
val account = _accounts.value.firstOrNull { it.id == accountId } ?: return@withLock
|
||||
if (account.id == _session.value?.accountId) return@withLock
|
||||
revoke { jellyfin.createApi(baseUrl = account.serverUrl, accessToken = account.accessToken, deviceInfo = api.deviceInfo) }
|
||||
_accounts.value = store.update(_session.value?.accountId) { list -> list.filterNot { it.id == accountId } }
|
||||
.sortedByDescending { it.lastUsedAt }
|
||||
}
|
||||
|
||||
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
|
||||
private suspend fun revoke(client: () -> ApiClient) {
|
||||
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
|
||||
.onFailure { Timber.w(it, "Token not revoked on the server") }
|
||||
}
|
||||
|
||||
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
|
||||
suspend fun <T> call(block: suspend ApiClient.() -> T): T = withContext(Dispatchers.IO) { api.block() }
|
||||
|
||||
@@ -233,5 +251,6 @@ class JellyfinSession @Inject constructor(
|
||||
const val CLIENT_NAME = "Movies"
|
||||
const val CLIENT_VERSION = "0.1.0"
|
||||
const val LOCAL_RECHECK_MS = 10 * 60_000L
|
||||
const val REVOKE_TIMEOUT_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user