feat(network): send Accept-Language header to addons

Adds an `Accept-Language` header on every addon HTTP call so Stremio-
compatible addons that maintain localized payloads (catalog names,
descriptions, …) can serve them in the user's language.

Resolution rules (mirrors NuvioTV PR #1766 with stricter region handling):

- User-picked in-app language (always region-less, e.g. `fr`):
  legacy behaviour `fr, en;q=0.7` — accept any French variant the addon serves.
- Otherwise device locale tag from `Locale.getDefault().toLanguageTag()` /
  `NSLocale.preferredLanguages.first`.
  - Regional locale (`fr-FR`, `pt-BR`, `es-MX`):
    strict chain `fr-FR, fr;q=0.9, en;q=0.5` — exact region first, then any
    variant of the same language, then English. Cross-region variants
    (e.g. `fr-CA`) are not preferred.
  - English (with or without region): tag itself, no q-suffix.
- Final fallback: `en`.

Header is added as a default — if the caller already specified
`Accept-Language` (case-insensitive), it is preserved untouched.

Affects every place in the app that displays a catalog name pulled from
an addon manifest: home rows, "see all" header, search → discover catalog
selector, addon reorder home catalogs screen, collection editor catalog
picker.

Adding a request header is fully backward-compatible — addons that
ignore it return identical payloads.
This commit is contained in:
Stéphane 2026-05-08 20:14:42 +02:00
parent 37203d1fc1
commit 8e97bac880
5 changed files with 89 additions and 5 deletions

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.addons
import java.util.Locale
internal actual fun deviceLanguageTag(): String? =
Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() }

View file

@ -71,6 +71,13 @@ private fun Map<String, String>.withoutAcceptEncoding(): Map<String, String> =
private fun Map<String, String>.getHeaderIgnoreCase(name: String): String? = private fun Map<String, String>.getHeaderIgnoreCase(name: String): String? =
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
private fun Map<String, String>.withDefaultAcceptLanguage(): Map<String, String> =
if (getHeaderIgnoreCase("Accept-Language") != null) {
this
} else {
this + ("Accept-Language" to buildAcceptLanguageHeader())
}
private data class LimitedReadResult( private data class LimitedReadResult(
val bytes: ByteArray, val bytes: ByteArray,
val truncated: Boolean, val truncated: Boolean,
@ -134,7 +141,7 @@ private suspend fun executeTextRequest(
body: String = "", body: String = "",
): String = withContext(Dispatchers.IO) { ): String = withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase() val normalizedMethod = method.uppercase()
val sanitizedHeaders = headers.withoutAcceptEncoding() val sanitizedHeaders = headers.withoutAcceptEncoding().withDefaultAcceptLanguage()
val builder = Request.Builder().url(url) val builder = Request.Builder().url(url)
sanitizedHeaders.forEach { (key, value) -> sanitizedHeaders.forEach { (key, value) ->
builder.header(key, value) builder.header(key, value)
@ -214,7 +221,7 @@ actual suspend fun httpRequestRaw(
): RawHttpResponse = ): RawHttpResponse =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val normalizedMethod = method.uppercase() val normalizedMethod = method.uppercase()
val sanitizedHeaders = headers.withoutAcceptEncoding() val sanitizedHeaders = headers.withoutAcceptEncoding().withDefaultAcceptLanguage()
val builder = Request.Builder().url(url) val builder = Request.Builder().url(url)
sanitizedHeaders.forEach { (key, value) -> sanitizedHeaders.forEach { (key, value) ->
builder.header(key, value) builder.header(key, value)

View file

@ -0,0 +1,50 @@
package com.nuvio.app.features.addons
import com.nuvio.app.features.settings.ThemeSettingsStorage
/**
* Returns the device's primary BCP-47 language tag (e.g. "fr-FR", "en", "es-MX"),
* or `null` if the platform can't determine one.
*/
internal expect fun deviceLanguageTag(): String?
/**
* Builds the value of the HTTP `Accept-Language` header that the addon HTTP
* client should send.
*
* Resolution rules:
* - If the user has explicitly picked an in-app language, that code wins
* (the in-app language is always region-less, e.g. `fr`).
* - Otherwise the device locale tag is used.
*
* Header format:
* - Regional locale (`fr-FR`, `pt-BR`, `es-MX`): strict chain
* `fr-FR, fr;q=0.9, en;q=0.5` accept the exact region first, then any
* variant of the same language, then English. Cross-region variants
* (e.g. `fr-CA`) are not preferred.
* - Language only (`fr`): legacy behaviour `fr, en;q=0.7` any French variant
* the addon serves is acceptable.
* - English (with or without region): just the tag itself, no q-suffix.
*/
internal fun buildAcceptLanguageHeader(): String {
val rawTag = ThemeSettingsStorage.loadSelectedAppLanguage()?.takeIf { it.isNotBlank() }
?: deviceLanguageTag()?.takeIf {
it.isNotBlank() && !it.equals("und", ignoreCase = true)
}
?: return "en"
val normalized = rawTag.replace('_', '-').trim()
val parts = normalized.split('-', limit = 2)
val language = parts[0].lowercase().takeIf { it.isNotBlank() } ?: return "en"
val region = parts.getOrNull(1)?.takeIf { it.isNotBlank() }?.uppercase()
if (language == "en") {
return if (region != null) "en-$region, en" else "en"
}
return if (region != null) {
"$language-$region, $language;q=0.9, en;q=0.5"
} else {
"$language, en;q=0.7"
}
}

View file

@ -0,0 +1,9 @@
package com.nuvio.app.features.addons
import platform.Foundation.NSLocale
import platform.Foundation.preferredLanguages
internal actual fun deviceLanguageTag(): String? {
val preferred = NSLocale.preferredLanguages.firstOrNull() as? String
return preferred?.takeIf { it.isNotBlank() }
}

View file

@ -46,10 +46,21 @@ private val addonHttpClient = HttpClient(Darwin) {
expectSuccess = false expectSuccess = false
} }
private fun Map<String, String>.hasHeaderIgnoreCase(name: String): Boolean =
keys.any { it.equals(name, ignoreCase = true) }
private fun Map<String, String>.withDefaultAcceptLanguage(): Map<String, String> =
if (hasHeaderIgnoreCase("Accept-Language")) {
this
} else {
this + ("Accept-Language" to buildAcceptLanguageHeader())
}
actual suspend fun httpGetText(url: String): String = actual suspend fun httpGetText(url: String): String =
addonHttpClient addonHttpClient
.get(url) { .get(url) {
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
header(HttpHeaders.AcceptLanguage, buildAcceptLanguageHeader())
} }
.let { response -> .let { response ->
val payload = response.bodyAsText() val payload = response.bodyAsText()
@ -67,6 +78,7 @@ actual suspend fun httpPostJson(url: String, body: String): String =
.post(url) { .post(url) {
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
header(HttpHeaders.AcceptLanguage, buildAcceptLanguageHeader())
setBody(body) setBody(body)
} }
.let { response -> .let { response ->
@ -87,7 +99,7 @@ actual suspend fun httpGetTextWithHeaders(
addonHttpClient addonHttpClient
.get(url) { .get(url) {
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
headers.forEach { (key, value) -> headers.withDefaultAcceptLanguage().forEach { (key, value) ->
header(key, value) header(key, value)
} }
} }
@ -111,7 +123,7 @@ actual suspend fun httpPostJsonWithHeaders(
.post(url) { .post(url) {
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
header(HttpHeaders.ContentType, ContentType.Application.Json.toString()) header(HttpHeaders.ContentType, ContentType.Application.Json.toString())
headers.forEach { (key, value) -> headers.withDefaultAcceptLanguage().forEach { (key, value) ->
header(key, value) header(key, value)
} }
setBody(body) setBody(body)
@ -138,7 +150,7 @@ actual suspend fun httpRequestRaw(
.request { .request {
url(url) url(url)
this.method = HttpMethod.parse(method.uppercase()) this.method = HttpMethod.parse(method.uppercase())
headers.forEach { (key, value) -> headers.withDefaultAcceptLanguage().forEach { (key, value) ->
header(key, value) header(key, value)
} }
if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) { if (this.method == HttpMethod.Post || this.method == HttpMethod.Put || this.method == HttpMethod.Patch) {