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 6887d7e41..5d7777e0a 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 @@ -26,6 +26,11 @@ internal object MetaDetailsParser { ?: error("Expected top-level JSON object in response") val meta = root.extractMetaObject() ?: error("Response did not contain a valid meta object") + meta.string("name")?.let { name -> + if (isAddonErrorSentinel(name)) { + error("Addon returned an error placeholder instead of meta: $name") + } + } val links = meta.links() val videos = meta.videos() @@ -117,6 +122,16 @@ internal object MetaDetailsParser { private fun JsonObject.looksLikeMetaObject(): Boolean = string("id") != null && string("type") != null && string("name") != null + /** + * Aggregator addons answer a failed upstream lookup with a well-formed meta object whose + * `name` is the failing sub-addon tagged `[❌]` and whose `description` carries the error + * text. It parses cleanly, so without this guard it gets stored as real metadata -- that is + * how a series ends up titled "[❌] Anime Kitsu" with the timeout message as its synopsis. + * Rejecting it lets the caller fall through to the next addon. + */ + private fun isAddonErrorSentinel(name: String): Boolean = + name.trimStart().startsWith("[\u274C]") + private fun JsonObject.ageRating(): String? { val appExtras = this["app_extras"] as? JsonObject return listOf( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 94e467eb2..5300dcc1d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -140,7 +140,9 @@ object MetaDetailsRepository { for (manifest in manifests) { val result = withContext(Dispatchers.Default) { - tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false) + withTimeoutOrNull(LOAD_FETCH_TIMEOUT_MS) { + tryFetchMeta(manifest, type, metaLookupId, includeMdbList = false) + } } if (result != null) { publishLoadedMeta( @@ -220,6 +222,14 @@ object MetaDetailsRepository { } private const val FETCH_TIMEOUT_MS = 5_000L + + /** + * Budget for one addon on the details screen. Larger than [FETCH_TIMEOUT_MS] because this is + * the interactive path and some series carry very large payloads (One Piece is ~1.3 MB), but + * bounded: without it the screen sits on an empty loading state until the 60s HTTP read + * timeout fires for every addon in turn, which reads as a permanently black screen. + */ + private const val LOAD_FETCH_TIMEOUT_MS = 20_000L private const val METADATA_PROVIDER_READY_TIMEOUT_MS = 10_000L private const val TMDB_ENRICH_TIMEOUT_MS = 5_000L private const val MDBLIST_ENRICH_TIMEOUT_MS = 5_000L diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index 3cedc3173..c01fdf9c2 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -15,6 +15,44 @@ class MetaDetailsParserTest { } } + @Test + fun `parse rejects aggregator error placeholder instead of storing it as meta`() { + // Verbatim shape an aggregator returns when a sub-addon lookup fails: a well-formed meta + // object whose name is the failing addon and whose description is the error text. + // Accepting it is what titled a series "[\u274C] Anime Kitsu" in stored watch progress. + assertFailsWith { + MetaDetailsParser.parse( + """ + { + "meta": { + "id": "tt0988824", + "type": "series", + "name": "[\u274C] Anime Kitsu", + "description": "Request for meta for Anime Kitsu timed out after 30000ms" + } + } + """.trimIndent(), + ) + } + } + + @Test + fun `parse keeps titles that merely contain brackets`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "tt0988824", + "type": "series", + "name": "[Dub] Naruto Shippuuden" + } + } + """.trimIndent(), + ) + + assertEquals("[Dub] Naruto Shippuuden", result.name) + } + @Test fun `parse accepts bare meta object response`() { val result = MetaDetailsParser.parse(