Fix possible TV guide crash & update Play All libraries (#315)

- Fixes a possible crash if the server has Live TV enabled, but no
channels
- Disable Play All/Shuffle for Collections "library"
This commit is contained in:
damontecres 2025-11-24 11:40:15 -05:00 committed by GitHub
parent 6004687deb
commit 7be4fb5fdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 156 additions and 131 deletions

View file

@ -52,56 +52,65 @@ data class GetItemsFilter(
return newFilter
}
fun applyTo(req: GetItemsRequest) =
req.copy(
includeItemTypes = includeItemTypes,
isFavorite = favorite,
genreIds = genres,
minCommunityRating = minCommunityRating,
personIds = persons,
isPlayed = played,
studioIds = studios,
tags = tags,
officialRatings = officialRatings,
years =
buildSet {
years?.letNotEmpty(::addAll)
decades?.forEach { addAll(it..<(it + 10)) }
},
is4k =
videoTypes?.letNotEmpty {
videoTypes.contains(FilterVideoType.FOUR_K).takeIf { it }
},
isHd =
videoTypes?.letNotEmpty {
if (videoTypes.contains(FilterVideoType.HD)) {
true
} else if (videoTypes.contains(FilterVideoType.SD)) {
false
} else {
null
}
},
is3d =
videoTypes?.letNotEmpty {
videoTypes.contains(FilterVideoType.THREE_D).takeIf { it }
},
videoTypes =
videoTypes?.letNotEmpty {
it.mapNotNull {
when (it) {
FilterVideoType.FOUR_K,
FilterVideoType.HD,
FilterVideoType.SD,
FilterVideoType.THREE_D,
-> null
/**
* Add the filtering from this into the [GetItemsRequest], overwriting the fields
*
* @param req the [GetItemsRequest]
* @param overwriteIncludeTypes whether the includeItemTypes field should be overwritten (used from this) or used as-is from the [GetItemsRequest]
*
*/
fun applyTo(
req: GetItemsRequest,
overwriteIncludeTypes: Boolean = true,
) = req.copy(
includeItemTypes = if (overwriteIncludeTypes) includeItemTypes else req.includeItemTypes,
isFavorite = favorite,
genreIds = genres,
minCommunityRating = minCommunityRating,
personIds = persons,
isPlayed = played,
studioIds = studios,
tags = tags,
officialRatings = officialRatings,
years =
buildSet {
years?.letNotEmpty(::addAll)
decades?.forEach { addAll(it..<(it + 10)) }
},
is4k =
videoTypes?.letNotEmpty {
videoTypes.contains(FilterVideoType.FOUR_K).takeIf { it }
},
isHd =
videoTypes?.letNotEmpty {
if (videoTypes.contains(FilterVideoType.HD)) {
true
} else if (videoTypes.contains(FilterVideoType.SD)) {
false
} else {
null
}
},
is3d =
videoTypes?.letNotEmpty {
videoTypes.contains(FilterVideoType.THREE_D).takeIf { it }
},
videoTypes =
videoTypes?.letNotEmpty {
it.mapNotNull {
when (it) {
FilterVideoType.FOUR_K,
FilterVideoType.HD,
FilterVideoType.SD,
FilterVideoType.THREE_D,
-> null
FilterVideoType.BLU_RAY -> VideoType.BLU_RAY
FilterVideoType.DVD -> VideoType.DVD
}
FilterVideoType.BLU_RAY -> VideoType.BLU_RAY
FilterVideoType.DVD -> VideoType.DVD
}
},
)
}
},
)
fun applyTo(req: GetPersonsRequest) =
req.copy(

View file

@ -11,6 +11,7 @@ import com.github.damontecres.wholphin.ui.DefaultItemFields
import com.github.damontecres.wholphin.ui.components.baseItemKinds
import com.github.damontecres.wholphin.ui.data.SortAndDirection
import com.github.damontecres.wholphin.ui.indexOfFirstOrNull
import com.github.damontecres.wholphin.ui.playback.playable
import com.github.damontecres.wholphin.ui.toServerString
import com.github.damontecres.wholphin.util.ApiRequestPager
import com.github.damontecres.wholphin.util.GetEpisodesRequestHandler
@ -93,26 +94,30 @@ class PlaylistCreator
item: BaseItemDto,
startIndex: Int = 0,
sortAndDirection: SortAndDirection?,
recursive: Boolean,
filter: GetItemsFilter,
): Playlist {
val includeItemTypes =
item.collectionType?.baseItemKinds?.takeIf { it.isNotEmpty() }
?: listOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO)
item.collectionType
?.baseItemKinds
?.filter { it.playable }
?.takeIf { it.isNotEmpty() }
?: BaseItemKind.entries.filter { it.playable }
val request =
filter.applyTo(
GetItemsRequest(
parentId = item.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = true,
excludeItemIds = listOf(item.id),
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
),
overwriteIncludeTypes = false,
req =
GetItemsRequest(
parentId = item.id,
enableImageTypes = listOf(ImageType.PRIMARY, ImageType.THUMB),
includeItemTypes = includeItemTypes,
recursive = true,
excludeItemIds = listOf(item.id),
sortBy = sortAndDirection?.let { listOf(sortAndDirection.sort) },
sortOrder = sortAndDirection?.let { listOf(sortAndDirection.direction) },
fields = DefaultItemFields,
startIndex = startIndex,
limit = Playlist.MAX_SIZE,
),
)
val items =
GetItemsRequestHandler.execute(api, request).content.items.map {
@ -144,7 +149,6 @@ class PlaylistCreator
} else {
sortAndDirection
},
recursive = recursive,
filter = filter,
),
)
@ -211,7 +215,7 @@ class PlaylistCreator
else -> PlaylistCreationResult.Error(null, "Unsupported type: ${item.type}")
}
suspend fun getPlaylists(
suspend fun getServerPlaylists(
mediaType: MediaType?,
scope: CoroutineScope,
): List<PlaylistInfo?> {
@ -234,7 +238,7 @@ class PlaylistCreator
}
}
suspend fun createPlaylist(
suspend fun createServerPlaylist(
name: String,
initialItems: List<UUID>,
): UUID? =
@ -251,14 +255,14 @@ class PlaylistCreator
.toUUIDOrNull()
}
suspend fun addToPlaylist(
suspend fun addToServerPlaylist(
playlistId: UUID,
itemId: UUID,
) {
api.playlistsApi.addItemToPlaylist(playlistId, listOf(itemId))
}
suspend fun removeFromPlaylist(
suspend fun removeFromServerPlaylist(
playlistId: UUID,
itemId: UUID,
) {

View file

@ -30,7 +30,7 @@ class AddPlaylistViewModel
viewModelScope.launchIO {
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Loading)
try {
val playlists = playlistCreator.getPlaylists(mediaType, viewModelScope)
val playlists = playlistCreator.getServerPlaylists(mediaType, viewModelScope)
this@AddPlaylistViewModel.playlistState.setValueOnMain(PlaylistLoadingState.Success(playlists))
} catch (ex: Exception) {
playlistState.setValueOnMain(PlaylistLoadingState.Error(ex))
@ -43,7 +43,7 @@ class AddPlaylistViewModel
itemId: UUID,
) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
playlistCreator.addToPlaylist(playlistId, itemId)
playlistCreator.addToServerPlaylist(playlistId, itemId)
}
}
@ -52,7 +52,7 @@ class AddPlaylistViewModel
itemId: UUID,
) {
viewModelScope.launchIO(ExceptionHandler(autoToast = true)) {
val playlistId = playlistCreator.createPlaylist(playlistName, listOf(itemId))
val playlistId = playlistCreator.createServerPlaylist(playlistName, listOf(itemId))
if (playlistId == null) {
showToast(context, "Error creating playlist", Toast.LENGTH_LONG)
}

View file

@ -27,6 +27,7 @@ fun CollectionFolderBoxSet(
modifier: Modifier = Modifier,
filter: GetItemsFilter = GetItemsFilter(),
preferencesViewModel: PreferencesViewModel = hiltViewModel(),
playEnabled: Boolean = false,
) {
var showHeader by remember { mutableStateOf(true) }
CollectionFolderGrid(
@ -44,6 +45,6 @@ fun CollectionFolderBoxSet(
showHeader = position < columns
},
params = CollectionFolderGridParameters.POSTER,
playEnabled = true,
playEnabled = playEnabled,
)
}

View file

@ -95,50 +95,57 @@ fun TvGuideGrid(
}
}
Column(modifier = modifier) {
TvGuideGridContent(
loading = state is LoadingState.Loading,
channels = channels,
programs = programs,
channelProgramCount = viewModel.channelProgramCount,
start = viewModel.guideStart,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = channel.id,
positionMs = 0L,
),
)
},
onFocus = {
onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it)
},
onClickProgram = { index, program ->
if (program.isFake) {
val now = LocalDateTime.now()
if (now.isAfter(program.start) && now.isBefore(program.end)) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = program.channelId,
positionMs = 0L,
),
)
if (channels.isEmpty()) {
ErrorMessage("Live TV is enabled, but no channels were found.", null)
} else {
TvGuideGridContent(
loading = state is LoadingState.Loading,
channels = channels,
programs = programs,
channelProgramCount = viewModel.channelProgramCount,
start = viewModel.guideStart,
onClickChannel = { index, channel ->
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = channel.id,
positionMs = 0L,
),
)
},
onFocus = {
onRowPosition.invoke(it.row)
viewModel.onFocusChannel(it)
},
onClickProgram = { index, program ->
if (program.isFake) {
val now = LocalDateTime.now()
if (now.isAfter(program.start) && now.isBefore(program.end)) {
viewModel.navigationManager.navigateTo(
Destination.Playback(
itemId = program.channelId,
positionMs = 0L,
),
)
} else {
Toast
.makeText(
context,
"No guide data found!",
Toast.LENGTH_SHORT,
).show()
}
} else {
Toast
.makeText(context, "No guide data found!", Toast.LENGTH_SHORT)
.show()
viewModel.getItem(program.id)
showItemDialog = index
}
} else {
viewModel.getItem(program.id)
showItemDialog = index
}
},
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.focusRequester(focusRequester),
)
},
modifier =
Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.focusRequester(focusRequester),
)
}
}
if (showItemDialog != null) {
val onDismissRequest = { showItemDialog = null }

View file

@ -103,11 +103,12 @@ fun DestinationContent(
BaseItemKind.BOX_SET ->
CollectionFolderBoxSet(
preferences,
destination.itemId,
destination.item,
false,
modifier,
preferences = preferences,
itemId = destination.itemId,
item = destination.item,
recursive = false,
playEnabled = true,
modifier = modifier,
)
BaseItemKind.PLAYLIST ->

View file

@ -37,21 +37,24 @@ val PrefContentScale.scale: ContentScale
val BaseItemKind.playable: Boolean
get() =
when (this) {
BaseItemKind.EPISODE,
BaseItemKind.MOVIE,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.TRAILER,
BaseItemKind.VIDEO,
-> true
// TODO add support for these eventually
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.LIVE_TV_PROGRAM,
BaseItemKind.PROGRAM,
BaseItemKind.RECORDING,
BaseItemKind.TV_CHANNEL,
BaseItemKind.TV_PROGRAM,
BaseItemKind.AUDIO_BOOK,
BaseItemKind.AUDIO,
BaseItemKind.CHANNEL,
BaseItemKind.EPISODE,
BaseItemKind.MOVIE,
BaseItemKind.LIVE_TV_CHANNEL,
BaseItemKind.LIVE_TV_PROGRAM,
BaseItemKind.MUSIC_VIDEO,
BaseItemKind.PROGRAM,
BaseItemKind.RECORDING,
BaseItemKind.TRAILER,
BaseItemKind.TV_CHANNEL,
BaseItemKind.TV_PROGRAM,
BaseItemKind.VIDEO,
-> true
-> false
BaseItemKind.AGGREGATE_FOLDER,
BaseItemKind.BASE_PLUGIN_FOLDER,