Delegate TrailerResolver to the shared fluxa-streaming-engine resolver

The Kotlin innertube client had drifted onto deprecated client configs
(ANDROID_TESTSUITE, TVHTML5_SIMPLY_EMBEDDED_PLAYER) that YouTube no
longer accepts. Route through FluxaStreamingNative's new
resolveYoutubeTrailerJson instead, so there's one maintained resolver
shared with the desktop app rather than two independent copies to keep
working. Public API (TrailerResolveResult/TrailerResult/SubtitleInfo)
is unchanged, so callers need no changes.
This commit is contained in:
KhooLy 2026-07-07 20:41:08 +03:00
parent 4c4a98432a
commit 3bbbcb206c
2 changed files with 37 additions and 178 deletions

View file

@ -1,20 +1,12 @@
package com.fluxa.app.ui.catalog
import android.util.Log
import com.fluxa.app.core.rust.FluxaStreamingNative
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
private const val CACHE_TTL_MS = 6 * 60 * 60 * 1000L
internal data class SubtitleInfo(
val languageTag: String,
@ -37,55 +29,11 @@ internal sealed interface TrailerResolveResult {
internal object TrailerResolver {
private val httpClient = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(20, TimeUnit.SECONDS)
.build()
private val memCache = ConcurrentHashMap<String, TrailerResolveResult>(16)
private var cacheFile: File? = null
private var cacheDir: File? = null
fun init(cacheDir: File) {
cacheFile = File(cacheDir, "trailer_url_cache.json")
loadDiskCache()
}
private fun loadDiskCache() {
val file = cacheFile ?: return
if (!file.exists()) return
runCatching {
val json = JSONObject(file.readText())
val now = System.currentTimeMillis()
val keys = json.keys()
while (keys.hasNext()) {
val id = keys.next()
val entry = json.getJSONObject(id)
val cachedAt = entry.getLong("cachedAt")
if (now - cachedAt > CACHE_TTL_MS) continue
val url = entry.getString("url")
memCache[id] = TrailerResolveResult.Ok(TrailerResult(url, emptyList()))
}
}.onFailure { Log.w("TrailerResolver", "disk cache load: ${it.message}") }
}
private fun saveToDiskCache(videoId: String, url: String) {
val file = cacheFile ?: return
runCatching {
val json = if (file.exists()) JSONObject(file.readText()) else JSONObject()
json.put(videoId, JSONObject().apply {
put("url", url)
put("cachedAt", System.currentTimeMillis())
})
val now = System.currentTimeMillis()
val pruned = JSONObject()
val keys = json.keys()
while (keys.hasNext()) {
val k = keys.next()
val entry = json.getJSONObject(k)
if (now - entry.getLong("cachedAt") <= CACHE_TTL_MS) pruned.put(k, entry)
}
file.writeText(pruned.toString())
}.onFailure { Log.w("TrailerResolver", "disk cache save: ${it.message}") }
this.cacheDir = File(cacheDir, "youtube-trailer-cache").apply { mkdirs() }
}
private fun videoId(youtubeUrl: String): String? =
@ -95,132 +43,38 @@ internal object TrailerResolver {
val id = videoId(youtubeUrl) ?: return@withContext TrailerResolveResult.Failed
memCache[id]?.let { return@withContext it }
val result = doExtract(id, androidClient())
if (result is TrailerResolveResult.Ok) {
memCache[id] = result
saveToDiskCache(id, result.data.streamUrl)
return@withContext result
val cacheDir = cacheDir ?: return@withContext TrailerResolveResult.Failed
val result = try {
val json = FluxaStreamingNative.resolveYoutubeTrailerJson(id, cacheDir.absolutePath)
?: return@withContext TrailerResolveResult.Failed
parseResult(JSONObject(json))
} catch (e: Exception) {
Log.e("TrailerResolver", "resolve($id) → ${e.javaClass.simpleName}: ${e.message}")
TrailerResolveResult.Failed
}
if (result is TrailerResolveResult.GeoBlocked) {
Log.d("TrailerResolver", "geo-blocked, retrying via embedded client")
val embedResult = doExtract(id, embeddedClient())
if (embedResult is TrailerResolveResult.Ok) {
memCache[id] = embedResult
saveToDiskCache(id, embedResult.data.streamUrl)
}
return@withContext embedResult
}
if (result is TrailerResolveResult.Ok) memCache[id] = result
result
}
private fun androidClient() = JSONObject().apply {
put("clientName", "ANDROID_TESTSUITE")
put("clientVersion", "1.9")
put("androidSdkVersion", 30)
put("hl", "en")
put("gl", "US")
}
private fun embeddedClient() = JSONObject().apply {
put("clientName", "TVHTML5_SIMPLY_EMBEDDED_PLAYER")
put("clientVersion", "2.0")
put("hl", "en")
put("gl", "US")
put("embedUrl", "https://www.youtube.com/")
}
private fun doExtract(videoId: String, client: JSONObject): TrailerResolveResult {
return try {
val body = JSONObject().apply {
put("videoId", videoId)
put("context", JSONObject().put("client", client))
}.toString()
val request = Request.Builder()
.url("https://www.youtube.com/youtubei/v1/player?prettyPrint=false")
.post(body.toRequestBody("application/json; charset=utf-8".toMediaType()))
.header("X-YouTube-Client-Name", client.optString("clientName"))
.header("X-YouTube-Client-Version", client.optString("clientVersion"))
.build()
val response = httpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return TrailerResolveResult.Failed
JSONObject(resp.body!!.string())
}
val status = response.optJSONObject("playabilityStatus")?.optString("status")
if (status == "UNPLAYABLE" || status == "LOGIN_REQUIRED") {
return TrailerResolveResult.GeoBlocked
}
val streamingData = response.optJSONObject("streamingData")
?: return TrailerResolveResult.Failed
val hlsUrl = streamingData.optString("hlsManifestUrl").takeIf { it.startsWith("http") }
if (hlsUrl != null) {
return TrailerResolveResult.Ok(TrailerResult(hlsUrl, extractSubtitles(response)))
}
val dashUrl = streamingData.optString("dashManifestUrl").takeIf { it.startsWith("http") }
if (dashUrl != null) {
return TrailerResolveResult.Ok(TrailerResult(dashUrl, extractSubtitles(response)))
}
val streamUrl = bestMp4(streamingData.optJSONArray("formats"))
?: bestMp4(streamingData.optJSONArray("adaptiveFormats"))
?: return TrailerResolveResult.Failed
TrailerResolveResult.Ok(TrailerResult(streamUrl, extractSubtitles(response)))
} catch (e: Exception) {
Log.e("TrailerResolver", "doExtract($videoId) → ${e.javaClass.simpleName}: ${e.message}")
TrailerResolveResult.Failed
}
}
private fun bestMp4(formats: JSONArray?): String? {
formats ?: return null
var bestUrl: String? = null
var bestHeight = -1
for (i in 0 until formats.length()) {
val fmt = formats.getJSONObject(i)
if (!fmt.optString("mimeType").startsWith("video/mp4")) continue
val url = fmt.optString("url").takeIf { it.startsWith("http") } ?: continue
val height = fmt.optString("qualityLabel").removeSuffix("p").toIntOrNull() ?: 0
if (height > bestHeight) {
bestHeight = height
bestUrl = url
}
}
return bestUrl
}
private fun extractSubtitles(response: JSONObject): List<SubtitleInfo> {
return runCatching {
val tracks = response
.optJSONObject("captions")
?.optJSONObject("playerCaptionsTracklistRenderer")
?.optJSONArray("captionTracks")
?: return emptyList()
(0 until tracks.length()).mapNotNull { i ->
val track = tracks.getJSONObject(i)
val baseUrl = track.optString("baseUrl").takeIf { it.startsWith("http") } ?: return@mapNotNull null
val lang = track.optString("languageCode").ifBlank { return@mapNotNull null }
val isAuto = track.optString("kind") == "asr"
val name = runCatching {
Locale.forLanguageTag(lang).getDisplayLanguage(Locale.ENGLISH)
.replaceFirstChar { it.uppercase() }
}.getOrElse { lang }
SubtitleInfo(
languageTag = lang,
label = if (isAuto) "$name (auto)" else name,
url = "$baseUrl&fmt=vtt",
mimeType = "text/vtt",
isAuto = isAuto
)
}.distinctBy { it.languageTag }
}.onFailure { Log.w("TrailerResolver", "subtitles: ${it.message}") }
.getOrElse { emptyList() }
private fun parseResult(response: JSONObject): TrailerResolveResult = when (response.optString("status")) {
"ok" -> TrailerResolveResult.Ok(
TrailerResult(
streamUrl = response.getString("streamUrl"),
subtitles = response.optJSONArray("subtitles")?.let { tracks ->
(0 until tracks.length()).map { i ->
val track = tracks.getJSONObject(i)
SubtitleInfo(
languageTag = track.getString("languageTag"),
label = track.getString("label"),
url = track.getString("url"),
mimeType = track.getString("mimeType"),
isAuto = track.getBoolean("isAuto")
)
}
} ?: emptyList()
)
)
"geo_blocked" -> TrailerResolveResult.GeoBlocked
else -> TrailerResolveResult.Failed
}
}

View file

@ -48,6 +48,10 @@ object FluxaStreamingNative {
fun parseMkvChapters(data: ByteArray): String = call { parseMkvChaptersNative(data) }
fun resolveYoutubeTrailerJson(videoId: String, cacheDir: String): String? = call {
resolveYoutubeTrailerJsonNative(videoId, cacheDir)
}
private inline fun <T> call(block: () -> T): T {
check(loaded) { "Fluxa streaming engine native library is not loaded." }
return block()
@ -64,4 +68,5 @@ object FluxaStreamingNative {
private external fun dvGetStreamStatsJsonNative(): String
private external fun dvGetCurrentL1JsonNative(): String
private external fun parseMkvChaptersNative(data: ByteArray): String
private external fun resolveYoutubeTrailerJsonNative(videoId: String, cacheDir: String): String?
}