Show PWA source ratings on Discover screens

This commit is contained in:
justinzeus 2026-10-29 20:00:31 +01:00
parent e92ade99df
commit 15b082ccd8
9 changed files with 53 additions and 131 deletions

View file

@ -3,8 +3,6 @@
package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.api.seerr.model.MovieMovieIdRatingsGet200Response
import com.github.damontecres.wholphin.api.seerr.model.TvTvIdRatingsGet200Response
import com.github.damontecres.wholphin.ui.detail.CardGridItem
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.util.LocalDateSerializer
@ -123,20 +121,3 @@ data class DiscoverItem(
}
}
}
/**
* A rating for a discovered item which is usually fetched separately from the item
*/
data class DiscoverRating(
val criticRating: Int?,
val audienceRating: Float?,
) {
constructor(rating: MovieMovieIdRatingsGet200Response) : this(
criticRating = rating.criticsScore,
audienceRating = rating.audienceScore?.div(10f),
)
constructor(rating: TvTvIdRatingsGet200Response) : this(
criticRating = rating.criticsScore,
audienceRating = null,
)
}

View file

@ -4,11 +4,12 @@ import android.content.Context
import android.content.res.Resources
import android.text.format.DateFormat
import androidx.annotation.StringRes
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.WholphinApplication
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.appendRatings
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.MediaSegmentType
@ -234,27 +235,16 @@ fun AnnotatedString.Builder.dot() = append(" \u2022 ")
fun listToDotString(
strings: List<String>,
communityRating: Float?,
criticRating: Float?,
ratings: SourceRatings?,
): AnnotatedString =
buildAnnotatedString {
strings.forEachIndexed { index, string ->
append(string)
if (index != strings.lastIndex) dot()
}
communityRating?.let {
dot()
append(String.format(Locale.getDefault(), "%.1f", it))
appendInlineContent(id = "star")
}
criticRating?.let {
dot()
append("${it.toInt()}%")
if (it >= 60f) {
appendInlineContent(id = "fresh")
} else {
appendInlineContent(id = "rotten")
}
ratings?.let {
appendRatings(it.communityEntries)
appendRatings(it.criticEntries)
}
}

View file

@ -38,7 +38,7 @@ import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.LocalTrailer
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -185,7 +185,7 @@ fun DiscoverMovieDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
movie: MovieDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
canCancel: Boolean,
people: List<DiscoverItem>,
trailers: List<Trailer>,

View file

@ -20,7 +20,7 @@ import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.ui.LocalImageUrlService
@ -44,7 +44,7 @@ import kotlin.time.Duration.Companion.minutes
fun DiscoverMovieDetailsHeader(
preferences: UserPreferences,
movie: MovieDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
bringIntoViewRequester: BringIntoViewRequester,
overviewOnClick: () -> Unit,
showLogo: Boolean,
@ -107,8 +107,7 @@ fun DiscoverMovieDetailsHeader(
}.let {
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
rating,
)
}
}

View file

@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.RequestStatus
import com.github.damontecres.wholphin.data.model.SeerrItemType
@ -19,9 +19,11 @@ import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.data.model.hasPermission
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.SeerrUserConfig
import com.github.damontecres.wholphin.services.TmdbVote
import com.github.damontecres.wholphin.ui.equalsNotNull
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
@ -60,6 +62,7 @@ class DiscoverMovieViewModel
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val seerrServerRepository: SeerrServerRepository,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@ -106,11 +109,11 @@ class DiscoverMovieViewModel
viewModelScope.launchIO {
val rating =
getDiscoverRating(item.id) {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = item.id),
)
}
ratingsService.get(
SeerrItemType.MOVIE,
item.id,
TmdbVote(movie.voteAverage?.toDouble(), movie.voteCount?.toLong()),
)
_state.update { it.copy(rating = rating) }
}
if (state.value.similar.isEmpty()) {
@ -276,7 +279,7 @@ fun canUserCancelRequest(
data class DiscoverMovieState(
val movie: DataLoadingState<MovieDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val rating: SourceRatings? = null,
val seasons: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),
val people: List<DiscoverItem> = emptyList(),

View file

@ -34,7 +34,7 @@ import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.SeerrPermission
import com.github.damontecres.wholphin.data.model.Trailer
@ -211,7 +211,7 @@ fun DiscoverSeriesDetailsContent(
preferences: UserPreferences,
userConfig: SeerrUserConfig?,
series: TvDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
canCancel: Boolean,
seasons: List<RequestSeason>,
similar: List<DiscoverItem>,
@ -422,7 +422,7 @@ fun DiscoverSeriesDetailsContent(
@Composable
fun DiscoverSeriesDetailsHeader(
series: TvDetails,
rating: DiscoverRating?,
rating: SourceRatings?,
overviewOnClick: () -> Unit,
showLogo: Boolean,
modifier: Modifier = Modifier,
@ -466,8 +466,7 @@ fun DiscoverSeriesDetailsHeader(
}.let {
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
rating,
)
}
}

View file

@ -10,7 +10,7 @@ import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutReques
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.RequestStatus
import com.github.damontecres.wholphin.data.model.SeerrAvailability
@ -18,8 +18,10 @@ import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrServerRepository
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.services.TmdbVote
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.launchDefault
import com.github.damontecres.wholphin.ui.launchIO
@ -60,6 +62,7 @@ class DiscoverSeriesViewModel
private val backdropService: BackdropService,
val serverRepository: ServerRepository,
val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
private val seerrServerRepository: SeerrServerRepository,
@Assisted val item: DiscoverItem,
) : ViewModel() {
@ -109,9 +112,11 @@ class DiscoverSeriesViewModel
viewModelScope.launchIO {
val rating =
getDiscoverRating(item.id) {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = item.id))
}
ratingsService.get(
SeerrItemType.TV,
item.id,
TmdbVote(tv.voteAverage?.toDouble(), tv.voteCount?.toLong()),
)
_state.update { it.copy(rating = rating) }
}
if (state.value.similar.isEmpty()) {
@ -416,7 +421,7 @@ class DiscoverSeriesViewModel
data class DiscoverSeriesState(
val tvSeries: DataLoadingState<TvDetails> = DataLoadingState.Pending,
val rating: DiscoverRating? = null,
val rating: SourceRatings? = null,
val seasons: List<RequestSeason> = emptyList(),
val seasons4k: List<RequestSeason> = emptyList(),
val trailers: List<Trailer> = emptyList(),

View file

@ -1,39 +1,16 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.Context
import com.github.damontecres.wholphin.api.seerr.infrastructure.ClientException
import com.github.damontecres.wholphin.api.seerr.model.MediaInfo
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.jellyfinId
import com.github.damontecres.wholphin.services.jellyfinIdAsString
import com.github.damontecres.wholphin.ui.isNotNullOrBlank
import com.github.damontecres.wholphin.ui.nav.Destination
import com.github.damontecres.wholphin.ui.showToast
import kotlinx.coroutines.CancellationException
import org.jellyfin.sdk.model.api.BaseItemKind
import timber.log.Timber
suspend fun getDiscoverRating(
id: Int,
func: suspend () -> DiscoverRating,
): DiscoverRating? =
try {
func.invoke()
} catch (ex: CancellationException) {
throw ex
} catch (ex: ClientException) {
if (ex.statusCode == 404) {
Timber.w("No rating for %s", id)
} else {
Timber.e(ex, "Error fetching rating for %s", id)
}
null
} catch (ex: Exception) {
Timber.e(ex, "Error fetching rating for %s", id)
null
}
/**
* Navigates to the Jellyfin media item if the [mediaInfo] has a valid [jellyfinId]
*

View file

@ -32,17 +32,17 @@ import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.data.filter.DiscoverFilter
import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.QuickDetailsData
import com.github.damontecres.wholphin.data.model.SeerrItemType
import com.github.damontecres.wholphin.data.model.SourceRatings
import com.github.damontecres.wholphin.preferences.UserPreferences
import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager
import com.github.damontecres.wholphin.services.SeerrRatingsService
import com.github.damontecres.wholphin.services.SeerrService
import com.github.damontecres.wholphin.ui.components.Genre
import com.github.damontecres.wholphin.ui.components.HeaderUtils
import com.github.damontecres.wholphin.ui.data.RowColumn
import com.github.damontecres.wholphin.ui.detail.discover.getDiscoverRating
import com.github.damontecres.wholphin.ui.launchIO
import com.github.damontecres.wholphin.ui.listToDotString
import com.github.damontecres.wholphin.ui.main.HomePageHeader
@ -57,9 +57,9 @@ import com.github.damontecres.wholphin.ui.util.StringProvider
import com.github.damontecres.wholphin.util.DataLoadingState
import com.github.damontecres.wholphin.util.DiscoverRequestType
import com.github.damontecres.wholphin.util.successValue
import com.google.common.cache.CacheBuilder
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.UUID
@ -73,11 +73,12 @@ class SeerrDiscoverViewModel
constructor(
@param:ApplicationContext private val context: Context,
private val seerrService: SeerrService,
private val ratingsService: SeerrRatingsService,
val navigationManager: NavigationManager,
private val backdropService: BackdropService,
) : ViewModel() {
val state = MutableStateFlow<DiscoverState>(DiscoverState())
val rating = MutableStateFlow<Map<Int, DiscoverRating>>(mapOf())
val rating = MutableStateFlow<Map<Pair<SeerrItemType, Int>, SourceRatings>>(mapOf())
init {
viewModelScope.launchIO {
@ -246,50 +247,23 @@ class SeerrDiscoverViewModel
}
}
private val ratingCache =
CacheBuilder
.newBuilder()
.maximumSize(100)
.build<Int, DiscoverRating>()
private var ratingJob: Job? = null
// TODO this is not very efficient
/**
* Fetch the ratings for the focused item, cancelling any previous request that is still running
*/
fun fetchRating(
id: Int,
type: SeerrItemType,
) {
viewModelScope.launchIO {
val cachedResult = ratingCache.getIfPresent(id)
if (cachedResult != null) {
return@launchIO
val key = type to id
if (key in rating.value) return
ratingJob?.cancel()
ratingJob =
viewModelScope.launchIO {
val result = ratingsService.get(type, id) ?: return@launchIO
rating.update { it + (key to result) }
}
val result =
getDiscoverRating(id) {
when (type) {
SeerrItemType.MOVIE -> {
DiscoverRating(
seerrService.api.moviesApi.movieMovieIdRatingsGet(movieId = id),
)
}
SeerrItemType.TV -> {
DiscoverRating(seerrService.api.tvApi.tvTvIdRatingsGet(tvId = id))
}
SeerrItemType.PERSON -> {
DiscoverRating(null, null)
}
SeerrItemType.UNKNOWN -> {
DiscoverRating(null, null)
}
}
} ?: DiscoverRating(null, null)
ratingCache.put(id, result)
rating.update {
ratingCache.asMap().toMap()
}
}
}
}
@ -424,14 +398,8 @@ fun SeerrDiscoverPage(
?.toString()
?.let(::add)
}.let {
val rating = focusedItem.id.let { ratingMap[it] }
val str =
listToDotString(
it,
rating?.audienceRating,
rating?.criticRating?.toFloat(),
)
QuickDetailsData(str)
val rating = ratingMap[focusedItem.item.type to focusedItem.id]
QuickDetailsData(listToDotString(it, rating))
}
}
}