feat: add lastAirDate to MetaDetails and related parsing logic for improved TV show metadata handling

This commit is contained in:
tapframe 2026-03-30 21:11:59 +05:30
parent cc7d6b6b59
commit 56ccb557ad
8 changed files with 140 additions and 2 deletions

View file

@ -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 }
}

View file

@ -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,

View file

@ -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"),

View file

@ -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 -"
}
}

View file

@ -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()) }
}

View file

@ -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<String>,
val writer: List<String>,
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,

View file

@ -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"))
}
}

View file

@ -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)),
)
}
}