From 6486bd19eda29007ca731a90a3e420288452e527 Mon Sep 17 00:00:00 2001 From: SergeiAK <17836309+SergeiAK@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:22:02 +0300 Subject: [PATCH] Fix person credits: movie release dates, full filmography, sorted order (#1950) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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: pr1873-1-before-start Before, end of the Discover row — the row stops here, and the only two years in it belong to the two TV entries: pr1873-2-before-end After, start of the row — years on the movie cards, ordered by date descending: pr1873-3-after-start After, end of the row — the list now reaches 1952, with the undated entries last: pr1873-4-after-end ## 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 Co-authored-by: Damontecres --- .../wholphin/services/SeerrService.kt | 64 ++++++++++++++----- .../wholphin/ui/detail/PersonPage.kt | 25 +++++++- .../ui/detail/discover/DiscoverPersonPage.kt | 17 +---- .../wholphin/ui/nav/Destination.kt | 2 + .../wholphin/ui/nav/DestinationContent.kt | 1 + 5 files changed, 77 insertions(+), 32 deletions(-) diff --git a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt index 8a283e4e..d60988a2 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/services/SeerrService.kt @@ -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 = + 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, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt index 8c9b05de..27212b5e 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/PersonPage.kt @@ -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 = 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() + }, ) } } diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt index 5987b7eb..2ce4a3a9 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/detail/discover/DiscoverPersonPage.kt @@ -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( @@ -137,6 +125,7 @@ fun DiscoverPersonPage( }, onClickPlay = { _, item -> }, + initialPosition = startIndex, letterPosition = { c: Char -> 0 }, gridFocusRequester = focusRequester, showJumpButtons = false, diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt index 081b18d0..a65c97e8 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/Destination.kt @@ -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 diff --git a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt index f8497a8f..63789d6c 100644 --- a/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt +++ b/app/src/main/java/com/github/damontecres/wholphin/ui/nav/DestinationContent.kt @@ -405,6 +405,7 @@ fun DestinationContent( LaunchedEffect(Unit) { onClearBackdrop.invoke() } DiscoverPersonPage( person = destination.item, + startIndex = destination.startIndex, modifier = modifier, ) }