From 2ef62c34b34e3e3519ca84ab8158a31ca24b3ccd Mon Sep 17 00:00:00 2001 From: Mitsu03 Date: Tue, 8 Sep 2026 22:50:12 +0100 Subject: [PATCH 1/2] Reject aggregator error placeholders instead of storing them as meta Aggregator addons answer a failed upstream lookup with a well-formed meta object rather than an error: the name field carries the failing sub-addon tagged with a cross mark, and the description carries the error text. It parses cleanly, so the parser accepted it as real metadata. The value then travelled well past the request that produced it. On a user's install a series ended up stored as "[X] Anime Kitsu" with "Request for meta for Anime Kitsu timed out after 30000ms" as its synopsis, in both watch progress and the Continue Watching enrichment cache, and surfaced later as the title in the player overlay -- on a different device from the one where the lookup had failed. Presence checks cannot catch this: the placeholder has an id, a type and a name, so it satisfies looksLikeMetaObject. Reject it explicitly so the caller falls through to the next addon, which is what should have happened when the lookup failed. Tests cover the verbatim placeholder payload and confirm that legitimate titles containing brackets, such as "[Dub] Naruto Shippuuden", still parse. --- .../app/features/details/MetaDetailsParser.kt | 15 ++++++++ .../features/details/MetaDetailsParserTest.kt | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+) 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/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( From 3fcca83f49fa80dd201948ad0eba0285ea1b4f35 Mon Sep 17 00:00:00 2001 From: Mitsu03 Date: Tue, 8 Sep 2026 22:50:22 +0100 Subject: [PATCH 2/2] Bound the details screen meta fetch per addon load() walked its meta manifests with no timeout at all, while fetch() -- the prefetch path reaching the same tryFetchMeta -- has wrapped each attempt in withTimeoutOrNull(FETCH_TIMEOUT_MS) all along. The asymmetry only shows when an addon accepts the connection and then stalls: the desktop HTTP client allows 60s to connect and 60s to read, so a details screen could sit on MetaDetailsUiState(isLoading = true) for a minute per addon in turn, with no meta to render behind it. That reads as a permanently black screen, and the in-flight guard means tapping again does nothing but wait. Use a separate 20s budget rather than reusing FETCH_TIMEOUT_MS: this is the interactive path and some payloads are genuinely large -- One Piece is about 1.3 MB across 1239 videos -- so 5s would give up on responses that were going to arrive. The point is an upper bound, not a tight one. --- .../app/features/details/MetaDetailsRepository.kt | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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