Search, favourites, person page and settings

This commit is contained in:
2026-10-20 12:00:01 +03:00
parent 757ef213f8
commit 4b8427310f
45 changed files with 6494 additions and 968 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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