Fix person credits: movie release dates, full filmography, sorted order (#1950)

<!-- By submitting this pull request, you acknowledge that you have read
the [contributing
guide](https://github.com/damontecres/Wholphin/blob/main/CONTRIBUTING.md,
including the AI/LLM policy, and [developer's
guide](https://github.com/damontecres/Wholphin/blob/main/DEVELOPMENT.md)
-->

## Description

Person pages that get a filmography from Seerr had three problems, all
in the same place.

The release date was taken from `firstAirDate` alone — the field TMDB
fills for series — so
movie cards showed no year at all. The library person page capped the
row at 25 cast + 25 crew while
`DiscoverPersonPage` had no cap, because each screen carried its own
copy of the same logic.
And `cast + crew` left the row in no order a viewer can read, with a
title appearing twice when
a person is both cast and crew on it.

This PR:

* maps credit dates as `releaseDate ?: firstAirDate` — the expression
the search result mapping
  already uses;
* moves the credits logic into a single `SeerrService.personCredits()`
used by both screens, so
  they cannot drift apart again;
* drops the 25 + 25 cap, de-duplicates by TMDB id and sorts by release
date descending, with
  undated entries last.

Rows on movie and series pages are untouched: only the
`BaseItemKind.PERSON` branch of
`similar()` changed.

Two things worth your opinion:

* Without the cap, the row for a person with a long filmography is long
— 135 cards for Cameron
Diaz, for example. If you would rather keep a cap, the row could keep
the newest 25 and gain a
`ViewMoreCard` opening the full grid — `Destination.DiscoveredItem` with
a `PERSON` item
already routes to `DiscoverPersonPage`. I am happy to add that in a
second commit.
* The sort is fixed to release date descending. It now lives in one
function, so a different key
  or a user-facing choice would be cheap to add later.

### Related issues

Fixes #1873

### Testing

CI in my fork ran the repository's own PR workflow on this branch:
`pre-commit` (ktlint) and
`assembleDefaultDebug` with `testDefaultDebugUnitTest` are both green.

I then installed a debug build of the same code on an NVIDIA Shield TV
Pro 2019 (Android 11),
against Jellyfin 10.11.11 with Jellyseerr, and opened Cameron Diaz from
the cast row of
*Charlie's Angels* before and after the change:

* movie cards show a year now; before, the only two cards in the row
with a year were the two
  TV entries;
* the row is ordered by release date descending, movies and series
interleaved, instead of the
  API order of all cast followed by all crew;
* the row is no longer cut at 25 + 25 — it now runs back to her 1952
credit, and the entries
  TMDB has no date for sit at the very end of the row.

Not tested on any other device; nothing in the change is device
specific.

## Screenshots

Person page of Cameron Diaz, reached from the cast row of *Charlie's
Angels*. The library rows
above the Discover row are the same in every shot and are useful as a
reference: they always had
their years.

Before, start of the Discover row — no year under any card:
<img width="1920" height="1080" alt="pr1873-1-before-start"
src="https://github.com/user-attachments/assets/585d0670-ae39-4214-9004-0c92262991da"
/>

Before, end of the Discover row — the row stops here, and the only two
years in it belong to the
two TV entries:
<img width="1920" height="1080" alt="pr1873-2-before-end"
src="https://github.com/user-attachments/assets/85c58575-2ad0-4369-a103-60f04c8d2350"
/>

After, start of the row — years on the movie cards, ordered by date
descending:
<img width="1920" height="1080" alt="pr1873-3-after-start"
src="https://github.com/user-attachments/assets/804745c5-e1a6-4fe4-93bd-dc6c97a7a876"
/>

After, end of the row — the list now reaches 1952, with the undated
entries last:
<img width="1920" height="1080" alt="pr1873-4-after-end"
src="https://github.com/user-attachments/assets/2c32389c-ec0b-4df5-b9d1-f564ef10d8b3"
/>

## AI or LLM usage

The code was written with Claude Opus 5 (Claude Code): the two mapping
lines, the extracted
`personCredits()` function and the two call sites. I reviewed every line
and can explain it.
This description was written by me in Russian and translated into
English with the same
assistant.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Damontecres <damontecres@gmail.com>
This commit is contained in:
SergeiAK 2026-09-11 22:22:02 +03:00 • committed by GitHub
parent 9c56965c82
commit 6486bd19ed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 77 additions and 32 deletions

View file

@ -124,21 +124,7 @@ class SeerrService
}
BaseItemKind.PERSON -> {
api.personApi
.personPersonIdCombinedCreditsGet(personId = it)
.let { credits ->
val cast =
credits.cast
?.take(25)
?.map { createDiscoverItem(it) }
.orEmpty()
val crew =
credits.crew
?.take(25)
?.map { createDiscoverItem(it) }
.orEmpty()
cast + crew
}
personCredits(it)
}
else -> {
@ -150,6 +136,50 @@ class SeerrService
null
}
/**
* Get the combined movie & TV credits of a person as [DiscoverItem]s
*
* The two lists returned by the API are merged, de-duplicated by id since a person can be
* both cast and crew on the same title, and sorted by release date descending with undated
* entries last
*/
suspend fun personCredits(personId: Int): List<DiscoverItem> =
api.personApi
.personPersonIdCombinedCreditsGet(personId = personId)
.let { credits ->
val cast = credits.cast?.map { createDiscoverItem(it) }.orEmpty()
val crew = credits.crew?.map { createDiscoverItem(it) }.orEmpty()
(cast + crew)
.distinctBy { it.id }
.sortedByDescending { it.releaseDate }
}
/**
* Create a [DiscoverItem] for a person in the library so that the full grid of their
* credits can be opened from their page
*
* @return the item or null if the person has no TMDB id
*/
fun discoverPerson(item: BaseItem): DiscoverItem? =
item.data.providerIds
?.get("Tmdb")
?.toIntOrNull()
?.let {
DiscoverItem(
id = it,
type = SeerrItemType.PERSON,
title = item.name,
subtitle = null,
overview = item.data.overview,
availability = SeerrAvailability.UNKNOWN,
releaseDate = null,
posterUrl = null,
backDropUrl = null,
logoUrl = null,
jellyfinItemId = null,
)
}
suspend fun getTvSeries(item: BaseItem): TvDetails? =
if (active.first()) {
item.data.providerIds
@ -424,7 +454,7 @@ class SeerrService
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.firstAirDate),
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,
@ -451,7 +481,7 @@ class SeerrService
availability =
SeerrAvailability.from(credit.mediaInfo?.status)
?: SeerrAvailability.UNKNOWN,
releaseDate = toLocalDate(credit.firstAirDate),
releaseDate = toLocalDate(credit.releaseDate ?: credit.firstAirDate),
posterUrl =
createImageUrl(
ImageType.PRIMARY,

View file

@ -157,6 +157,7 @@ class PersonViewModel
_state.update {
it.copy(
discovered = results,
discoverPerson = seerrService.discoverPerson(person),
)
}
}
@ -221,6 +222,7 @@ data class PersonState(
val series: RowLoadingState = RowLoadingState.Pending,
val episodes: RowLoadingState = RowLoadingState.Pending,
val discovered: List<DiscoverItem> = emptyList(),
val discoverPerson: DiscoverItem? = null,
)
@Composable
@ -257,6 +259,12 @@ fun PersonPage(
imageType = ImageType.PRIMARY,
)
}
// A long filmography is hard to navigate as a row, so it is cut at the same
// limit as the home page rows and the rest is left to the full grid
val maxItems = preferences.appPreferences.homePagePreferences.maxItemsPerRow
val discovered =
remember(state.discovered, maxItems) { state.discovered.take(maxItems) }
PersonPageContent(
preferences = preferences,
name = name,
@ -276,10 +284,18 @@ fun PersonPage(
favoriteOnClick = {
viewModel.setFavorite(!person.favorite)
},
discovered = state.discovered,
discovered = discovered,
onClickDiscover = { index, item ->
viewModel.navigationManager.navigateTo(item.destination)
},
onClickViewMoreDiscover = {
state.discoverPerson?.let {
viewModel.navigationManager.navigateTo(
Destination.DiscoveredItem(it, maxItems),
)
}
},
enableViewMoreDiscover = state.discovered.size > discovered.size,
modifier = modifier,
)
AnimatedVisibility(showOverviewDialog) {
@ -323,6 +339,8 @@ fun PersonPageContent(
overviewOnClick: () -> Unit,
favoriteOnClick: () -> Unit,
onClickDiscover: (Int, DiscoverItem) -> Unit,
onClickViewMoreDiscover: () -> Unit,
enableViewMoreDiscover: Boolean,
modifier: Modifier = Modifier,
) {
val focusRequester = remember { FocusRequester() }
@ -437,6 +455,11 @@ fun PersonPageContent(
onLongClickItem = { _, _ -> },
onCardFocus = {},
focusRequester = focusRequester,
enableViewMore = enableViewMoreDiscover,
onClickViewMore = {
position = RowColumn(DISCOVER_ROW, discovered.size)
onClickViewMoreDiscover.invoke()
},
)
}
}

View file

@ -61,20 +61,7 @@ class DiscoverPersonViewModel
viewModelScope.launchIO {
backdropService.clearBackdrop()
val credits =
seerrService.api.personApi
.personPersonIdCombinedCreditsGet(personId = item.id)
.let { credits ->
val cast =
credits.cast
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
val crew =
credits.crew
?.map { seerrService.createDiscoverItem(it) }
.orEmpty()
cast + crew
}
val credits = seerrService.personCredits(item.id)
this@DiscoverPersonViewModel.credits.update {
DataLoadingState.Success(credits)
}
@ -85,6 +72,7 @@ class DiscoverPersonViewModel
@Composable
fun DiscoverPersonPage(
person: DiscoverItem,
startIndex: Int,
modifier: Modifier = Modifier,
viewModel: DiscoverPersonViewModel =
hiltViewModel<DiscoverPersonViewModel, DiscoverPersonViewModel.Factory>(
@ -137,6 +125,7 @@ fun DiscoverPersonPage(
},
onClickPlay = { _, item ->
},
initialPosition = startIndex,
letterPosition = { c: Char -> 0 },
gridFocusRequester = focusRequester,
showJumpButtons = false,

View file

@ -151,6 +151,8 @@ sealed class Destination(
@Serializable
data class DiscoveredItem(
val item: DiscoverItem,
// Index to scroll to on the person page grid
val startIndex: Int = 0,
) : Destination(false)
@Serializable

View file

@ -405,6 +405,7 @@ fun DestinationContent(
LaunchedEffect(Unit) { onClearBackdrop.invoke() }
DiscoverPersonPage(
person = destination.item,
startIndex = destination.startIndex,
modifier = modifier,
)
}