From 56ccb557ade6f30702f4a7b7e27cb6342fa61f40 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:11:59 +0530 Subject: [PATCH] feat: add lastAirDate to MetaDetails and related parsing logic for improved TV show metadata handling --- .../app/core/format/ReleaseDateDisplay.kt | 14 ++++ .../app/features/details/MetaDetailsModels.kt | 2 + .../app/features/details/MetaDetailsParser.kt | 1 + .../details/MetaDetailsReleaseLine.kt | 35 ++++++++++ .../details/components/DetailMetaInfo.kt | 4 +- .../app/features/tmdb/TmdbMetadataService.kt | 7 ++ .../app/core/format/ReleaseDateDisplayTest.kt | 10 +++ .../details/MetaDetailsReleaseLineTest.kt | 69 +++++++++++++++++++ 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLine.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLineTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt index 93d977620..87616ba53 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt @@ -30,3 +30,17 @@ fun formatReleaseDateForDisplay(raw: String): String { val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return raw return "$year ${MONTH_NAMES[month - 1]} $day" } + +/** + * Parses a release/air string (ISO date, year-only, or timestamp prefix) for compact UI (e.g. year chips). + */ +fun extractReleaseYearForDisplay(raw: String): Int? { + val t = raw.trim() + if (t.isEmpty()) return null + if (t.length == 4 && t.all { it.isDigit() }) { + return t.toIntOrNull()?.takeIf { it in 1000..9999 } + } + val datePart = t.substringBefore('T').trim() + val yearStr = datePart.split('-').firstOrNull() ?: return null + return yearStr.toIntOrNull()?.takeIf { it in 1000..9999 } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index a1f46ea8c..f31e4fa0a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -12,6 +12,8 @@ data class MetaDetails( val logo: String? = null, val description: String? = null, val releaseInfo: String? = null, + /** TV: ISO last air date from TMDB (or addon) for year-range display. */ + val lastAirDate: String? = null, val status: String? = null, val imdbRating: String? = null, val ageRating: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 79a93117a..3a1f06f96 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -32,6 +32,7 @@ internal object MetaDetailsParser { logo = meta.string("logo"), description = meta.string("description"), releaseInfo = meta.string("releaseInfo"), + lastAirDate = meta.string("lastAirDate"), status = meta.string("status"), imdbRating = meta.string("imdbRating"), ageRating = meta.string("ageRating"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLine.kt new file mode 100644 index 000000000..aace263e3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLine.kt @@ -0,0 +1,35 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.core.format.extractReleaseYearForDisplay + +private fun isTvSeriesType(type: String): Boolean = + when (type.trim().lowercase()) { + "series", "tv", "show", "tvshow" -> true + else -> false + } + +private fun isEndedSeriesStatus(status: String?): Boolean { + if (status.isNullOrBlank()) return false + val s = status.trim().lowercase() + if ("returning" in s || "in production" in s) return false + return "ended" in s || "canceled" in s || "cancelled" in s +} + +/** + * Compact release line under the details hero: movies → year only; TV → "2025 -" or "2021 - 2028". + */ +fun formatMetaReleaseLineForDetails(meta: MetaDetails): String? { + val raw = meta.releaseInfo?.trim()?.takeIf { it.isNotEmpty() } ?: return null + if (!isTvSeriesType(meta.type)) { + return extractReleaseYearForDisplay(raw)?.toString() + } + val startYear = extractReleaseYearForDisplay(raw) ?: return raw + val endYear = meta.lastAirDate?.let { extractReleaseYearForDisplay(it) } + return when { + isEndedSeriesStatus(meta.status) && endYear != null -> + if (endYear == startYear) startYear.toString() + else "$startYear - $endYear" + isEndedSeriesStatus(meta.status) -> startYear.toString() + else -> "$startYear -" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index 715da0522..b7e6bb0d5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -25,8 +25,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.details.formatMetaReleaseLineForDetails @Composable fun DetailMetaInfo( @@ -38,7 +38,7 @@ fun DetailMetaInfo( verticalArrangement = Arrangement.spacedBy(12.dp), ) { val infoParts = buildList { - meta.releaseInfo?.let { add(formatReleaseDateForDisplay(it)) } + formatMetaReleaseLineForDetails(meta)?.let { add(it) } meta.ageRating?.let { add(it) } meta.runtime?.let { add(it.uppercase()) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index 172b8adf0..ba1f944f7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -101,6 +101,7 @@ object TmdbMetadataService { if (enrichment != null && settings.useDetails) { updated = updated.copy( releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, + lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, status = enrichment.status ?: updated.status, ageRating = enrichment.ageRating ?: updated.ageRating, runtime = enrichment.runtimeMinutes?.formatRuntime() ?: updated.runtime, @@ -263,6 +264,8 @@ object TmdbMetadataService { val people = buildPeople(details = details, credits = credits, mediaType = mediaType) val directors = buildDirectors(details = details, credits = credits, mediaType = mediaType) val writers = buildWriters(credits = credits, mediaType = mediaType, hasDirectors = directors.isNotEmpty()) + val lastAirDate = details.lastAirDate?.trim()?.takeIf(String::isNotBlank) + ?.takeIf { mediaType == "tv" } val enrichment = TmdbEnrichment( localizedTitle = localizedTitle, description = description, @@ -274,6 +277,7 @@ object TmdbMetadataService { director = directors, writer = writers, releaseInfo = releaseInfo, + lastAirDate = lastAirDate, rating = details.voteAverage, runtimeMinutes = details.runtime ?: details.episodeRunTime.firstOrNull(), ageRating = response.fourth.first, @@ -453,6 +457,7 @@ internal data class TmdbEnrichment( val director: List, val writer: List, val releaseInfo: String?, + val lastAirDate: String? = null, val rating: Double?, val runtimeMinutes: Int?, val ageRating: String?, @@ -476,6 +481,7 @@ internal data class TmdbEnrichment( director.isNotEmpty() || writer.isNotEmpty() || releaseInfo != null || + lastAirDate != null || rating != null || runtimeMinutes != null || ageRating != null || @@ -725,6 +731,7 @@ private data class TmdbDetailsResponse( val overview: String? = null, @SerialName("release_date") val releaseDate: String? = null, @SerialName("first_air_date") val firstAirDate: String? = null, + @SerialName("last_air_date") val lastAirDate: String? = null, val status: String? = null, @SerialName("vote_average") val voteAverage: Double? = null, val runtime: Int? = null, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt index 176f82f2d..749f07d0f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt @@ -23,4 +23,14 @@ class ReleaseDateDisplayTest { fun leavesNonIsoUnchanged() { assertEquals("TBA", formatReleaseDateForDisplay("TBA")) } + + @Test + fun extractsYearFromIso() { + assertEquals(2025, extractReleaseYearForDisplay("2025-03-15")) + } + + @Test + fun extractsYearFromYearOnly() { + assertEquals(2024, extractReleaseYearForDisplay("2024")) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLineTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLineTest.kt new file mode 100644 index 000000000..13b5288d1 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsReleaseLineTest.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.details + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MetaDetailsReleaseLineTest { + private fun meta( + type: String, + releaseInfo: String?, + status: String? = null, + lastAirDate: String? = null, + ) = MetaDetails( + id = "1", + type = type, + name = "X", + releaseInfo = releaseInfo, + status = status, + lastAirDate = lastAirDate, + ) + + @Test + fun movieShowsYearOnly() { + assertEquals("2019", formatMetaReleaseLineForDetails(meta("movie", "2019-07-04"))) + } + + @Test + fun ongoingSeriesShowsOpenRange() { + assertEquals( + "2025 -", + formatMetaReleaseLineForDetails( + meta("series", "2025-01-10", status = "Returning Series", lastAirDate = "2025-03-01"), + ), + ) + } + + @Test + fun endedSeriesShowsClosedRange() { + assertEquals( + "2021 - 2028", + formatMetaReleaseLineForDetails( + meta("series", "2021-09-01", status = "Ended", lastAirDate = "2028-05-20"), + ), + ) + } + + @Test + fun endedSameYearSingleYear() { + assertEquals( + "2022", + formatMetaReleaseLineForDetails( + meta("series", "2022-06-01", status = "Ended", lastAirDate = "2022-12-01"), + ), + ) + } + + @Test + fun nullReleaseInfo() { + assertNull(formatMetaReleaseLineForDetails(meta("movie", null))) + } + + @Test + fun endedWithoutLastAirDateShowsSingleYear() { + assertEquals( + "2020", + formatMetaReleaseLineForDetails(meta("series", "2020-04-01", status = "Ended", lastAirDate = null)), + ) + } +}