Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<IllegalStateException> {
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(
Expand Down
Loading