Fixes to season/episode search with duplicates (#1946)

## Description
Fixes cases where the server has duplicate season IDs causing the season
index search to fail and default to the first season in the list.

### Related issues
Fixes #1837

### Testing
Emulator & unit tests

## Screenshots
N/A

## AI or LLM usage
None
This commit is contained in:
Damontecres 2026-09-08 22:51:46 -04:00 • committed by GitHub
parent 1778bdb34c
commit 4a567cbcf4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 108 additions and 35 deletions

View file

@ -92,7 +92,7 @@ fun SeriesOverview(
LaunchedEffect(Unit) {
if (state.seasons.isNotEmpty()) {
state.seasons.getOrNull(position.seasonTabIndex)?.let {
viewModel.loadEpisodes(it.id)
viewModel.loadEpisodes(it.id, it.indexNumber)
}
}
}
@ -210,7 +210,7 @@ fun SeriesOverview(
onChangeSeason = { index ->
if (index != position.seasonTabIndex) {
state.seasons.getOrNull(index)?.let { season ->
viewModel.loadEpisodes(season.id)
viewModel.loadEpisodes(season.id, season.indexNumber)
viewModel.position.update {
SeriesOverviewPosition(index, 0)
}

View file

@ -143,6 +143,7 @@ class SeriesViewModel
if (seasonEpisodeIds != null) {
loadEpisodesInternal(
seasonEpisodeIds.seasonId,
seasonEpisodeIds.seasonNumber,
seasonEpisodeIds.episodeId,
seasonEpisodeIds.episodeNumber,
)
@ -150,6 +151,7 @@ class SeriesViewModel
seasonsDeferred.await().firstOrNull()?.let {
loadEpisodesInternal(
it.id,
it.indexNumber,
null,
null,
)
@ -178,10 +180,11 @@ class SeriesViewModel
val index =
(seasons as? ApiRequestPager<*>)?.let {
findIndexByNumberOrIdFast(
seasonEpisodeIds.seasonNumber,
seasonEpisodeIds.seasonId,
it,
null,
targetNum = seasonEpisodeIds.seasonNumber,
targetId = seasonEpisodeIds.seasonId,
list = it,
parentId = null,
parentIndex = null,
)
} ?: 0
Timber.v("Got initial season index: $index")
@ -340,6 +343,7 @@ class SeriesViewModel
private suspend fun loadEpisodesInternal(
seasonId: UUID,
seasonNum: Int?,
episodeId: UUID?,
episodeNumber: Int?,
): EpisodeList {
@ -368,8 +372,13 @@ class SeriesViewModel
pager.init(episodeNumber ?: 0)
val initialIndex =
if (episodeId != null || episodeNumber != null) {
findIndexByNumberOrIdFast(episodeNumber, episodeId, pager, seasonId)
.coerceAtLeast(0)
findIndexByNumberOrIdFast(
targetNum = episodeNumber,
targetId = episodeId,
list = pager,
parentId = seasonId,
parentIndex = seasonNum,
).coerceAtLeast(0)
} else {
// Force the first page to be fetched
if (pager.isNotEmpty()) {
@ -381,7 +390,10 @@ class SeriesViewModel
return EpisodeList.Success(seasonId, pager, initialIndex)
}
fun loadEpisodes(seasonId: UUID) {
fun loadEpisodes(
seasonId: UUID,
seasonNum: Int?,
) {
val currentEpisodes = (state.value.episodes as? EpisodeList.Success)
if (currentEpisodes == null || currentEpisodes.seasonId != seasonId) {
_state.update {
@ -395,7 +407,7 @@ class SeriesViewModel
viewModelScope.launchIO(ExceptionHandler(true)) {
val episodes =
try {
loadEpisodesInternal(seasonId, null, null)
loadEpisodesInternal(seasonId, seasonNum, null, null)
} catch (e: Exception) {
Timber.e(e, "Error loading episodes for $seriesId for season $seasonId")
EpisodeList.Error(e)
@ -716,12 +728,18 @@ private fun checkNumberOrId(
targetId: UUID?,
indexNumber: Int?,
id: UUID?,
): Boolean =
if (targetId != null) {
equalsNotNull(targetId, id)
} else {
equalsNotNull(indexNumber, targetNum)
}
): Boolean = equalsNotNull(targetId, id) || equalsNotNull(indexNumber, targetNum)
private fun checkParent(
parentId: UUID?,
parentIndex: Int?,
item: BaseItem?,
) = if (parentId != null || parentIndex != null) {
equalsNotNull(parentId, item?.data?.parentId) ||
equalsNotNull(parentIndex, item?.data?.parentIndexNumber)
} else {
true
}
/**
* Find the index in the [list] where the item's `indexNumber`==[targetNum] or `id`==[targetId]
@ -734,19 +752,22 @@ suspend fun findIndexByNumberOrIdFast(
targetId: UUID?,
list: ApiRequestPager<*>,
parentId: UUID?,
parentIndex: Int?,
): Int =
if (list.size <= list.pageSize) {
Timber.v("Using findIndexByNumberOrIdFast indexOfBlocking method")
list.indexOfBlocking {
checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) &&
if (parentId != null) {
it?.data?.parentId == parentId
} else {
true
}
checkParent(parentId, parentIndex, it)
}
} else {
findIndexByNumberOrId(targetNum, targetId, list as BlockingList<BaseItem?>, parentId)
findIndexByNumberOrId(
targetNum,
targetId,
list as BlockingList<BaseItem?>,
parentId,
parentIndex,
)
}
/**
@ -765,6 +786,7 @@ suspend fun findIndexByNumberOrId(
targetId: UUID?,
list: BlockingList<BaseItem?>,
parentId: UUID? = null,
parentIndex: Int? = null,
): Int {
Timber.v("Using findIndexByNumberOrId")
// Adjust for 1-based numbers
@ -777,7 +799,7 @@ suspend fun findIndexByNumberOrId(
checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id)
}.coerceAtLeast(0)
} else if (listIndex != null && listIndex in list.indices) {
searchList(listIndex, targetNum, targetId, list, parentId)
searchList(listIndex, targetNum, targetId, list, parentId, parentIndex)
} else {
0
}
@ -790,22 +812,42 @@ private suspend fun searchList(
targetId: UUID?,
list: BlockingList<BaseItem?>,
parentId: UUID?,
parentIndex: Int?,
): Int {
val item = list.getBlocking(listIndex)
if (parentId != null && item?.data?.parentId != parentId) {
return if (listIndex - 1 in list.indices) {
searchList(listIndex - 1, targetNum, targetId, list, parentId)
} else if (listIndex + 1 in list.indices) {
searchList(listIndex + 1, targetNum, targetId, list, parentId)
} else {
0
var index = listIndex
var item = list.getBlocking(index)
if ((parentId != null || parentIndex != null) && !checkParent(parentId, parentIndex, item)) {
var leftIndex = listIndex - 1
var rightIndex = listIndex + 1
// Search before & after to find an episode with the right parent
while (leftIndex in list.indices || rightIndex in list.indices) {
if (leftIndex in list.indices) {
val left = list.getBlocking(leftIndex)
if (checkParent(parentId, parentIndex, left)) {
index = leftIndex
break
}
}
if (rightIndex in list.indices) {
val right = list.getBlocking(rightIndex)
if (checkParent(parentId, parentIndex, right)) {
index = rightIndex
break
}
}
leftIndex--
rightIndex++
}
// Search for the right episode starting from this new one
item = list.getBlocking(index)
}
val num = item?.indexNumber
if (num.lt(targetNum)) {
for (i in listIndex + 1 until list.size) {
val item = list.getBlocking(i)
if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) {
if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) &&
checkParent(parentId, parentIndex, item)
) {
return i
}
}
@ -813,15 +855,22 @@ private suspend fun searchList(
} else if (num.gt(targetNum)) {
for (i in listIndex - 1 downTo 0) {
val item = list.getBlocking(i)
if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id)) {
if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) &&
checkParent(parentId, parentIndex, item)
) {
return i
}
}
return 0
} else if (checkNumberOrId(targetNum, targetId, item?.indexNumber, item?.id) &&
checkParent(parentId, parentIndex, item)
) {
return index
} else {
return list
.indexOfBlocking {
checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id)
checkNumberOrId(targetNum, targetId, it?.indexNumber, it?.id) &&
checkParent(parentId, parentIndex, item)
}.coerceAtLeast(0)
}
}

View file

@ -156,7 +156,12 @@ class TestFindIndexByNumberOrId {
fun `Test with special matching number`() =
runTest {
val list = listOf(epS02E01, epS00E02, epS02E02)
findIndexByNumberOrId(targetNum = 2, targetId = epS02E02.id, list = BlockingList.of(list)).let { index ->
findIndexByNumberOrId(
targetNum = 2,
targetId = epS02E02.id,
list = BlockingList.of(list),
parentIndex = 2,
).let { index ->
Assert.assertEquals(2, index)
}
}
@ -195,4 +200,23 @@ class TestFindIndexByNumberOrId {
Assert.assertEquals(4, index)
}
}
@Test
fun `Test with non-matching parent ID`() =
runTest {
val altSeason2Id = UUID.randomUUID()
Assert.assertNotEquals(altSeason2Id, season2Id)
Assert.assertNotEquals(altSeason2Id, epS02E02.data.parentId)
val list = listOf(epS02E01, epS02E02)
findIndexByNumberOrId(
targetNum = 2,
targetId = epS02E02.id,
list = BlockingList.of(list),
parentId = altSeason2Id,
parentIndex = 2,
).let { index ->
Assert.assertEquals(1, index)
}
}
}