Request individual seasons from Jellyseerr (#1060)

## Description
Adds a dialog to select individual seasons for a TV show when requesting
it via Discover/Jellyseerr

If a request is already pending, it can be updated. If there's more than
7 seasons, a second submit button is added at the bottom of the dialog
for easier access.

Also, adds a button from Jellyfin series details to its Discover
equivalent and added a request button for partially available series to
request more seasons.

### Related issues
Closes #961

### Testing
Emulator & Jellyseerr 2.7.3

## Screenshots
![discover_seasons
Large](https://github.com/user-attachments/assets/c7781bf4-7c1b-4e4e-a0b8-a572111a8497)

## AI or LLM usage
None
This commit is contained in:
Ray 2026-03-10 11:54:00 -04:00 committed by GitHub
parent 49a6cd8d2f
commit afc47f254b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 512 additions and 46 deletions

View file

@ -2,6 +2,7 @@
package com.github.damontecres.wholphin.data.model package com.github.damontecres.wholphin.data.model
import androidx.compose.runtime.Stable
import com.github.damontecres.wholphin.api.seerr.model.CreditCast import com.github.damontecres.wholphin.api.seerr.model.CreditCast
import com.github.damontecres.wholphin.api.seerr.model.CreditCrew import com.github.damontecres.wholphin.api.seerr.model.CreditCrew
import com.github.damontecres.wholphin.api.seerr.model.MovieDetails import com.github.damontecres.wholphin.api.seerr.model.MovieDetails
@ -74,6 +75,7 @@ enum class SeerrAvailability(
/** /**
* An item provided by a discovery service (ie Seerr). It may exist on the JF server as well. * An item provided by a discovery service (ie Seerr). It may exist on the JF server as well.
*/ */
@Stable
@Serializable @Serializable
data class DiscoverItem( data class DiscoverItem(
val id: Int, val id: Int,

View file

@ -57,6 +57,7 @@ class SeerrServerRepository
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server } connection.map { (it as? SeerrConnectionStatus.Success)?.current?.server }
val currentUser: Flow<SeerrUser?> = val currentUser: Flow<SeerrUser?> =
connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user } connection.map { (it as? SeerrConnectionStatus.Success)?.current?.user }
val currentUserId: Flow<Int?> = current.map { it?.config?.id }
/** /**
* Whether Seerr integration is currently active of not * Whether Seerr integration is currently active of not

View file

@ -2,6 +2,7 @@ package com.github.damontecres.wholphin.services
import com.github.damontecres.wholphin.api.seerr.SeerrApiClient import com.github.damontecres.wholphin.api.seerr.SeerrApiClient
import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner import com.github.damontecres.wholphin.api.seerr.model.SearchGet200ResponseResultsInner
import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -125,4 +126,16 @@ class SeerrService
} else { } else {
null null
} }
suspend fun getTvSeries(item: BaseItem): TvDetails? =
if (active.first()) {
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
?.let { tvId ->
api.tvApi.tvTvIdGet(tvId = tvId)
}
} else {
null
}
} }

View file

@ -35,7 +35,6 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.tv.material3.MaterialTheme import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text import androidx.tv.material3.Text
import com.github.damontecres.wholphin.R import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.model.BaseItem import com.github.damontecres.wholphin.data.model.BaseItem
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
@ -99,6 +98,7 @@ fun DiscoverSeriesDetails(
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
var seasonDialog by remember { mutableStateOf<DialogParams?>(null) } var seasonDialog by remember { mutableStateOf<DialogParams?>(null) }
var moreDialog by remember { mutableStateOf<DialogParams?>(null) } var moreDialog by remember { mutableStateOf<DialogParams?>(null) }
var showRequestSeasonDialog by remember { mutableStateOf(false) }
val requestStr = stringResource(R.string.request) val requestStr = stringResource(R.string.request)
val request4kStr = stringResource(R.string.request_4k) val request4kStr = stringResource(R.string.request_4k)
@ -159,26 +159,7 @@ fun DiscoverSeriesDetails(
trailers = trailers, trailers = trailers,
requestOnClick = { requestOnClick = {
item.id?.let { id -> item.id?.let { id ->
if (request4kEnabled) { showRequestSeasonDialog = true
moreDialog =
DialogParams(
fromLongClick = false,
title = item.name ?: "",
items =
listOf(
DialogItem(
text = requestStr,
onClick = { viewModel.request(id, false) },
),
DialogItem(
text = request4kStr,
onClick = { viewModel.request(id, true) },
),
),
)
} else {
viewModel.request(id, false)
}
} }
}, },
cancelOnClick = { cancelOnClick = {
@ -218,6 +199,18 @@ fun DiscoverSeriesDetails(
waitToLoad = params.fromLongClick, waitToLoad = params.fromLongClick,
) )
} }
if (showRequestSeasonDialog) {
RequestSeasonsDialog(
title = item?.name ?: "",
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = { seasons, is4k ->
item?.id?.let { viewModel.request(it, seasons, is4k) }
showRequestSeasonDialog = false
},
onDismissRequest = { showRequestSeasonDialog = false },
)
}
} }
private const val HEADER_ROW = 0 private const val HEADER_ROW = 0
@ -234,7 +227,7 @@ fun DiscoverSeriesDetailsContent(
series: TvDetails, series: TvDetails,
rating: DiscoverRating?, rating: DiscoverRating?,
canCancel: Boolean, canCancel: Boolean,
seasons: List<Season>, seasons: List<RequestSeason>,
similar: List<DiscoverItem>, similar: List<DiscoverItem>,
recommended: List<DiscoverItem>, recommended: List<DiscoverItem>,
trailers: List<Trailer>, trailers: List<Trailer>,
@ -299,6 +292,7 @@ fun DiscoverSeriesDetailsContent(
SeerrAvailability.from(series.mediaInfo?.status) SeerrAvailability.from(series.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN, ?: SeerrAvailability.UNKNOWN,
requestOnClick = requestOnClick, requestOnClick = requestOnClick,
pendingOnClick = requestOnClick,
cancelOnClick = cancelOnClick, cancelOnClick = cancelOnClick,
moreOnClick = moreOnClick, moreOnClick = moreOnClick,
goToOnClick = goToOnClick, goToOnClick = goToOnClick,

View file

@ -6,12 +6,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo import com.github.damontecres.wholphin.api.seerr.model.RelatedVideo
import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest import com.github.damontecres.wholphin.api.seerr.model.RequestPostRequest
import com.github.damontecres.wholphin.api.seerr.model.Season import com.github.damontecres.wholphin.api.seerr.model.RequestRequestIdPutRequest
import com.github.damontecres.wholphin.api.seerr.model.TvDetails import com.github.damontecres.wholphin.api.seerr.model.TvDetails
import com.github.damontecres.wholphin.data.ServerRepository import com.github.damontecres.wholphin.data.ServerRepository
import com.github.damontecres.wholphin.data.model.DiscoverItem import com.github.damontecres.wholphin.data.model.DiscoverItem
import com.github.damontecres.wholphin.data.model.DiscoverRating import com.github.damontecres.wholphin.data.model.DiscoverRating
import com.github.damontecres.wholphin.data.model.RemoteTrailer import com.github.damontecres.wholphin.data.model.RemoteTrailer
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.data.model.Trailer import com.github.damontecres.wholphin.data.model.Trailer
import com.github.damontecres.wholphin.services.BackdropService import com.github.damontecres.wholphin.services.BackdropService
import com.github.damontecres.wholphin.services.NavigationManager import com.github.damontecres.wholphin.services.NavigationManager
@ -33,6 +34,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -63,7 +65,7 @@ class DiscoverSeriesViewModel
val tvSeries = MutableLiveData<TvDetails?>(null) val tvSeries = MutableLiveData<TvDetails?>(null)
val rating = MutableLiveData<DiscoverRating?>(null) val rating = MutableLiveData<DiscoverRating?>(null)
val seasons = MutableLiveData<List<Season>>(listOf()) val seasons = MutableLiveData<List<RequestSeason>>(listOf())
val trailers = MutableLiveData<List<Trailer>>(listOf()) val trailers = MutableLiveData<List<Trailer>>(listOf())
val people = MutableLiveData<List<DiscoverItem>>(listOf()) val people = MutableLiveData<List<DiscoverItem>>(listOf())
val similar = MutableLiveData<List<DiscoverItem>>() val similar = MutableLiveData<List<DiscoverItem>>()
@ -103,6 +105,7 @@ class DiscoverSeriesViewModel
val discoveredItem = DiscoverItem(tv) val discoveredItem = DiscoverItem(tv)
backdropService.submit(discoveredItem) backdropService.submit(discoveredItem)
updateSeasonStatus()
updateCanCancel() updateCanCancel()
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@ -157,6 +160,43 @@ class DiscoverSeriesViewModel
navigationManager.navigateTo(destination) navigationManager.navigateTo(destination)
} }
private suspend fun updateSeasonStatus() {
tvSeries.value?.let { tv ->
val seasonStatus = mutableMapOf<Int, SeerrAvailability>()
tv.seasons?.forEach {
it.seasonNumber?.let {
seasonStatus[it] = SeerrAvailability.UNKNOWN
}
}
val tvStatus =
SeerrAvailability.from(tv.mediaInfo?.status) ?: SeerrAvailability.UNKNOWN
tv.mediaInfo
?.requests
?.forEach {
it.seasons?.mapNotNull { season ->
season.seasonNumber?.let {
val current = seasonStatus[season.seasonNumber]
val new =
SeerrAvailability
.from(season.status)
?.takeIf { it != SeerrAvailability.UNKNOWN } ?: tvStatus
if (current == null || new.status > current.status) {
seasonStatus[season.seasonNumber] = new
}
}
}
}
Timber.v("seasonStatus=%s", seasonStatus)
val requestSeasons =
seasonStatus.mapNotNull { (seasonNumber, availability) ->
tv.seasons?.firstOrNull { it.seasonNumber == seasonNumber }?.let {
RequestSeason(it, availability)
}
}
seasons.setValueOnMain(requestSeasons)
}
}
private suspend fun updateCanCancel() { private suspend fun updateCanCancel() {
val user = userConfig.firstOrNull() val user = userConfig.firstOrNull()
val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests) val canCancel = canUserCancelRequest(user, tvSeries.value?.mediaInfo?.requests)
@ -165,20 +205,43 @@ class DiscoverSeriesViewModel
fun request( fun request(
id: Int, id: Int,
seasons: Set<Int>,
is4k: Boolean, is4k: Boolean,
) { ) {
viewModelScope.launchIO { viewModelScope.launchIO {
val request = tvSeries.value?.let { tv ->
seerrService.api.requestApi.requestPost( val currentRequest =
RequestPostRequest( tv.mediaInfo?.requests?.firstOrNull {
is4k = is4k, it.requestedBy?.id ==
mediaId = id, seerrServerRepository.currentUserId.first()
mediaType = RequestPostRequest.MediaType.TV, }
seasons = RequestPostRequest.Seasons.ALL, // TODO handle picking seasons if (currentRequest != null) {
), Timber.v("User has pending request, will update")
) seerrService.api.requestApi.requestRequestIdPut(
fetchAndSetItem().await() requestId = currentRequest.id.toString(),
updateCanCancel() requestRequestIdPutRequest =
RequestRequestIdPutRequest(
is4k = is4k,
mediaType = RequestRequestIdPutRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
} else {
Timber.v("New request for %s seasons", seasons.size)
seerrService.api.requestApi.requestPost(
RequestPostRequest(
is4k = is4k,
mediaId = id,
mediaType = RequestPostRequest.MediaType.TV,
seasons = seasons.toList(),
),
)
}
fetchAndSetItem().await()
updateSeasonStatus()
updateCanCancel()
}
} }
} }

View file

@ -37,6 +37,7 @@ fun ExpandableDiscoverButtons(
trailerOnClick: (Trailer) -> Unit, trailerOnClick: (Trailer) -> Unit,
buttonOnFocusChanged: (FocusState) -> Unit, buttonOnFocusChanged: (FocusState) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
pendingOnClick: () -> Unit = {},
) { ) {
val firstFocus = remember { FocusRequester() } val firstFocus = remember { FocusRequester() }
LazyRow( LazyRow(
@ -89,7 +90,7 @@ fun ExpandableDiscoverButtons(
SeerrAvailability.PENDING, SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING, SeerrAvailability.PROCESSING,
-> { -> {
// TODO? pendingOnClick.invoke()
} }
SeerrAvailability.PARTIALLY_AVAILABLE, SeerrAvailability.PARTIALLY_AVAILABLE,
@ -109,6 +110,21 @@ fun ExpandableDiscoverButtons(
.onFocusChanged(buttonOnFocusChanged), .onFocusChanged(buttonOnFocusChanged),
) )
} }
if (availability == SeerrAvailability.PARTIALLY_AVAILABLE) {
item("request_partial") {
ExpandableFaButton(
title = R.string.request,
iconStringRes = R.string.fa_download,
onClick = {
requestOnClick.invoke()
},
enabled = availability == SeerrAvailability.PARTIALLY_AVAILABLE,
modifier =
Modifier
.onFocusChanged(buttonOnFocusChanged),
)
}
}
if (canCancel) { if (canCancel) {
item("cancel") { item("cancel") {

View file

@ -0,0 +1,323 @@
package com.github.damontecres.wholphin.ui.detail.discover
import android.content.res.Configuration.UI_MODE_TYPE_TELEVISION
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableStateSetOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Button
import androidx.tv.material3.ClickableSurfaceDefaults
import androidx.tv.material3.ListItem
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Switch
import androidx.tv.material3.Text
import androidx.tv.material3.contentColorFor
import com.github.damontecres.wholphin.R
import com.github.damontecres.wholphin.api.seerr.model.Season
import com.github.damontecres.wholphin.data.model.SeerrAvailability
import com.github.damontecres.wholphin.ui.cards.AvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PartiallyAvailableIndicator
import com.github.damontecres.wholphin.ui.cards.PendingIndicator
import com.github.damontecres.wholphin.ui.components.BasicDialog
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
data class RequestSeason(
val season: Season,
val availability: SeerrAvailability,
)
@Composable
fun RequestSeasons(
title: String,
seasons: List<RequestSeason>,
onSubmit: (Set<Int>, Boolean) -> Unit,
request4kEnabled: Boolean,
modifier: Modifier,
) {
val allSeasonNumbers = remember(seasons) { seasons.mapNotNull { it.season.seasonNumber }.toSet() }
val selected =
remember {
mutableStateSetOf<Int>(
*seasons
.mapNotNull {
if (it.availability > SeerrAvailability.UNKNOWN) {
it.season.seasonNumber
} else {
null
}
}.toTypedArray(),
)
}
var is4k by remember { mutableStateOf(false) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier,
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth(),
)
LazyColumn {
item {
val isSelected = selected.containsAll(allSeasonNumbers)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
ClickSwitch(
label = stringResource(R.string.select_all),
checked = isSelected,
onClick = {
if (isSelected) {
selected.removeAll(allSeasonNumbers)
} else {
selected.addAll(allSeasonNumbers)
}
},
)
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
if (request4kEnabled) {
item {
ClickSwitch(
label = stringResource(R.string.request_4k),
checked = is4k,
onClick = { is4k = !is4k },
)
}
}
itemsIndexed(seasons) { index, season ->
val seasonNumber = season.season.seasonNumber
val isSelected = seasonNumber in selected
SeasonListItem(
season = season,
selected = isSelected,
onClick = {
if (isSelected) {
selected.remove(seasonNumber)
} else {
seasonNumber?.let { selected.add(it) }
}
},
modifier = Modifier,
)
}
if (seasons.size > 7) {
item {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp),
) {
Button(
onClick = { onSubmit.invoke(selected, is4k) },
) {
Text(
text = stringResource(R.string.submit),
)
}
}
}
}
}
}
}
@Composable
fun SeasonListItem(
season: RequestSeason,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ListItem(
selected = false,
headlineContent = {
Text(
text =
season.season.name
?: (stringResource(R.string.tv_season) + " ${season.season.seasonNumber}"),
)
},
supportingContent = {
season.season.episodeCount?.let {
Text(
// TODO should use plurals string
text = "${season.season.episodeCount} " + stringResource(R.string.episodes),
)
}
},
leadingContent = {
when (season.availability) {
SeerrAvailability.UNKNOWN -> {}
SeerrAvailability.DELETED -> {}
SeerrAvailability.PENDING,
SeerrAvailability.PROCESSING,
-> {
PendingIndicator()
}
SeerrAvailability.PARTIALLY_AVAILABLE -> {
PartiallyAvailableIndicator()
}
SeerrAvailability.AVAILABLE -> {
AvailableIndicator()
}
}
},
trailingContent = {
Row {
Switch(
checked = selected,
onCheckedChange = {
onClick.invoke()
},
)
}
},
onClick = onClick,
modifier = modifier,
)
}
@Composable
private fun ClickSurface(
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable BoxScope.() -> Unit,
) {
Surface(
colors =
ClickableSurfaceDefaults.colors(
containerColor = Color.Transparent,
contentColor = MaterialTheme.colorScheme.onSurface,
focusedContainerColor = MaterialTheme.colorScheme.inverseSurface,
focusedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
pressedContainerColor = MaterialTheme.colorScheme.inverseSurface,
pressedContentColor = contentColorFor(MaterialTheme.colorScheme.inverseSurface),
),
onClick = onClick,
content = content,
modifier = modifier,
)
}
@Composable
private fun ClickSwitch(
label: String,
checked: Boolean,
onClick: () -> Unit,
) {
ClickSurface(
onClick = onClick,
modifier = Modifier,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(horizontal = 8.dp)
.height(54.dp),
) {
Switch(
checked = checked,
onCheckedChange = {},
modifier = Modifier.padding(end = 8.dp),
)
Text(
text = label,
)
}
}
}
@Composable
fun RequestSeasonsDialog(
title: String,
seasons: List<RequestSeason>,
request4kEnabled: Boolean,
onSubmit: (Set<Int>, Boolean) -> Unit,
onDismissRequest: () -> Unit,
) {
BasicDialog(
onDismissRequest = onDismissRequest,
) {
RequestSeasons(
title = title,
seasons = seasons,
request4kEnabled = request4kEnabled,
onSubmit = onSubmit,
modifier = Modifier.padding(16.dp),
)
}
}
@Preview(
device = "spec:parent=tv_1080p",
backgroundColor = 0xFF383535,
uiMode = UI_MODE_TYPE_TELEVISION,
heightDp = 800,
)
@Composable
fun RequestSeasonsPreview() {
val seasons =
List(10) {
RequestSeason(
season =
Season(
seasonNumber = it + 1,
episodeCount = 10 + it,
),
availability =
if (it < 3) {
SeerrAvailability.AVAILABLE
} else {
SeerrAvailability.UNKNOWN
},
)
}
WholphinTheme {
RequestSeasons(
title = "Series title",
seasons = seasons,
request4kEnabled = true,
onSubmit = { _, _ -> },
modifier = Modifier.width(400.dp),
)
}
}

View file

@ -1,6 +1,9 @@
package com.github.damontecres.wholphin.ui.detail.series package com.github.damontecres.wholphin.ui.detail.series
import android.content.Context import android.content.Context
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.focusGroup import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -120,6 +123,7 @@ fun SeriesDetails(
val people by viewModel.people.observeAsState(listOf()) val people by viewModel.people.observeAsState(listOf())
val similar by viewModel.similar.observeAsState(listOf()) val similar by viewModel.similar.observeAsState(listOf())
val discovered by viewModel.discovered.collectAsState() val discovered by viewModel.discovered.collectAsState()
val discoverSeries by viewModel.discoverSeries.collectAsState()
val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending) val playlistState by playlistViewModel.playlistState.observeAsState(PlaylistLoadingState.Pending)
var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) } var overviewDialog by remember { mutableStateOf<ItemDetailsDialogInfo?>(null) }
@ -230,6 +234,12 @@ fun SeriesDetails(
onClickExtra = { _, extra -> onClickExtra = { _, extra ->
viewModel.navigateTo(extra.destination) viewModel.navigateTo(extra.destination)
}, },
discoverSeries = discoverSeries,
onClickDiscoverSeries = {
discoverSeries?.let {
viewModel.navigateTo(Destination.DiscoveredItem(it))
}
},
discovered = discovered, discovered = discovered,
onClickDiscover = { index, item -> onClickDiscover = { index, item ->
viewModel.navigateTo(item.destination) viewModel.navigateTo(item.destination)
@ -350,6 +360,8 @@ fun SeriesDetailsContent(
onClickExtra: (Int, ExtrasItem) -> Unit, onClickExtra: (Int, ExtrasItem) -> Unit,
moreActions: MoreDialogActions, moreActions: MoreDialogActions,
onClickDiscover: (Int, DiscoverItem) -> Unit, onClickDiscover: (Int, DiscoverItem) -> Unit,
discoverSeries: DiscoverItem?,
onClickDiscoverSeries: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val context = LocalContext.current val context = LocalContext.current
@ -487,6 +499,25 @@ fun SeriesDetailsContent(
}, },
) )
} }
AnimatedVisibility(
visible = discoverSeries != null,
enter = fadeIn(),
exit = fadeOut(),
) {
ExpandableFaButton(
title = R.string.discover,
iconStringRes = R.string.fa_magnifying_glass_plus,
onClick = onClickDiscoverSeries,
modifier =
Modifier.onFocusChanged {
if (it.isFocused) {
scope.launch(ExceptionHandler()) {
bringIntoViewRequester.bringIntoView()
}
}
},
)
}
} }
} }
item { item {

View file

@ -58,6 +58,7 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@ -123,6 +124,7 @@ class SeriesViewModel
val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem()) val peopleInEpisode = MutableLiveData<PeopleInItem>(PeopleInItem())
val discovered = MutableStateFlow<List<DiscoverItem>>(listOf()) val discovered = MutableStateFlow<List<DiscoverItem>>(listOf())
val discoverSeries = MutableStateFlow<DiscoverItem?>(null)
val position = MutableStateFlow(SeriesOverviewPosition(0, 0)) val position = MutableStateFlow(SeriesOverviewPosition(0, 0))
@ -232,6 +234,22 @@ class SeriesViewModel
val results = seerrService.similar(item).orEmpty() val results = seerrService.similar(item).orEmpty()
discovered.update { results } discovered.update { results }
} }
viewModelScope.launchIO {
seerrService.active.collectLatest { active ->
val tv =
if (active) {
try {
seerrService.getTvSeries(item)?.let { DiscoverItem(it) }
} catch (ex: Exception) {
Timber.e(ex)
null
}
} else {
null
}
discoverSeries.update { tv }
}
}
} }
mediaManagementService.deletedItemFlow mediaManagementService.deletedItemFlow
.onEach { deletedItem -> .onEach { deletedItem ->

View file

@ -98,6 +98,7 @@ class SwitchSeerrViewModel
) )
} }
} }
serverConnectionStatus.update { LoadingState.Success }
} else { } else {
val message = val message =
results results

View file

@ -719,5 +719,6 @@
<string name="creator">Creator</string> <string name="creator">Creator</string>
<string name="artist">Artist</string> <string name="artist">Artist</string>
<string name="search_for">Search %s</string> <string name="search_for">Search %s</string>
<string name="select_all">Select all</string>
</resources> </resources>

View file

@ -1086,6 +1086,11 @@ components:
type: array type: array
items: items:
$ref: '#/components/schemas/Episode' $ref: '#/components/schemas/Episode'
status:
type: integer
example: 0
description: Status of the request. 1 = PENDING APPROVAL, 2 = APPROVED, 3 = DECLINED
readOnly: true
TvDetails: TvDetails:
type: object type: object
properties: properties:
@ -1268,6 +1273,10 @@ components:
type: string type: string
type: type:
type: string type: string
seasons:
type: array
items:
$ref: '#/components/schemas/Season'
required: required:
- id - id
- status - status
@ -6135,16 +6144,10 @@ paths:
type: integer type: integer
example: 123 example: 123
seasons: seasons:
# oneOf: type: array
# - type: array items:
# items: type: integer
# type: integer minimum: 0
# minimum: 0
# - type: string
# enum: [all]
# TODO
type: string
enum: [all]
nullable: true nullable: true
is4k: is4k:
type: boolean type: boolean