app/src/main/kotlin/com/honjimaku/subrep/Cloud.kt (5261 bytes)
1 package com.honjimaku.subrep 2 3 import okhttp3.HttpUrl.Companion.toHttpUrl 4 import okhttp3.MediaType.Companion.toMediaType 5 import okhttp3.OkHttpClient 6 import okhttp3.Request 7 import okhttp3.RequestBody.Companion.toRequestBody 8 import okhttp3.Response 9 import org.json.JSONObject 10 import java.io.IOException 11 12 /** 13 * Cloud captions on subread.space. The account is the device cookie of the site, kept in 14 * [Store.device]: the first answer of the server makes it. Hours bought with [checkout] go to 15 * that account. 16 * 17 * The device token is read and written through two functions, so a test needs no [Store]. 18 */ 19 class Cloud( 20 private val http: OkHttpClient, 21 private val readDevice: () -> String, 22 private val writeDevice: (String) -> Unit, 23 private val base: String = BASE, 24 ) { 25 26 constructor(http: OkHttpClient, store: Store) : this(http, { store.device }, { store.device = it }) 27 28 /** The piece is lost, but the next can work: the network, or the speech service. */ 29 class PieceFailed(message: String) : Exception(message) 30 31 /** The account has no hours for this piece. */ 32 class OutOfHours : Exception("No cloud hours left. Buy hours, or choose the phone.") 33 34 data class Pack(val id: String, val name: String, val hours: Int, val price: String) 35 36 data class State(val accountId: String, val secondsLeft: Long, val available: Boolean, val packs: List<Pack>) 37 38 data class Answer(val text: String, val secondsLeft: Long) 39 40 /** The balance and the packs for sale. Throws [IOException] without the network. */ 41 fun state(): State = call(Request.Builder().url("$base/api/captions").build()).use { response -> 42 if (!response.isSuccessful) throw IOException("subread.space answered ${response.code}") 43 parseState(response.body!!.string()) 44 } 45 46 /** The address of the payment page for [packId]. It opens in the browser. */ 47 fun checkout(packId: String): String { 48 val body = JSONObject().put("pack_id", packId).toString().toRequestBody(JSON) 49 return call(Request.Builder().url("$base/api/captions/checkout").post(body).build()).use { response -> 50 val text = response.body!!.string() 51 if (!response.isSuccessful) throw IOException(detail(text) ?: "subread.space answered ${response.code}") 52 JSONObject(text).getString("url") 53 } 54 } 55 56 /** The text of [pcm], 16 kHz mono 16-bit little-endian PCM. */ 57 fun transcribe(pcm: ByteArray, language: String): Answer { 58 val request = Request.Builder() 59 .url("$base/api/captions/transcribe".toHttpUrl().newBuilder().addQueryParameter("lang", language.ifEmpty { "auto" }).build()) 60 .post(pcm.toRequestBody(PCM)) 61 .build() 62 val response = try { 63 call(request) 64 } catch (e: IOException) { 65 throw PieceFailed("Cloud: no connection (${e.message}).") 66 } 67 return response.use { 68 val text = it.body!!.string() 69 when (it.code) { 70 200 -> JSONObject(text).let { json -> Answer(json.getString("text"), json.getLong("seconds_left")) } 71 402 -> throw OutOfHours() 72 else -> throw PieceFailed("Cloud: ${detail(text) ?: "the server answered ${it.code}"}.") 73 } 74 } 75 } 76 77 private fun call(request: Request): Response { 78 val device = readDevice() 79 val response = http.newCall( 80 if (device.isEmpty()) request else request.newBuilder().header("Cookie", "$COOKIE=$device").build(), 81 ).execute() 82 deviceFrom(response.headers("Set-Cookie"))?.let { if (it != device) writeDevice(it) } 83 return response 84 } 85 86 companion object { 87 const val BASE = "https://subread.space" 88 const val COOKIE = "subplz_device" 89 private val JSON = "application/json".toMediaType() 90 private val PCM = "application/octet-stream".toMediaType() 91 92 /** The device token in the Set-Cookie headers of an answer, or null. */ 93 fun deviceFrom(setCookies: List<String>): String? = setCookies.firstNotNullOfOrNull { header -> 94 header.substringBefore(';').trim().takeIf { it.startsWith("$COOKIE=") } 95 ?.substringAfter('=')?.trim('"')?.takeIf { it.isNotEmpty() } 96 } 97 98 fun parseState(text: String): State { 99 val json = JSONObject(text) 100 val packs = json.getJSONArray("packs") 101 return State( 102 accountId = json.optString("account_id"), 103 secondsLeft = json.getLong("seconds_left"), 104 available = json.getBoolean("available"), 105 packs = (0 until packs.length()).map { i -> 106 packs.getJSONObject(i).run { Pack(getString("id"), getString("name"), getInt("hours"), getString("price_display")) } 107 }, 108 ) 109 } 110 111 /** The "detail" of a FastAPI error answer, or null. */ 112 private fun detail(text: String): String? = runCatching { JSONObject(text).getString("detail") }.getOrNull() 113 114 /** "12 h 5 min" for [seconds]. */ 115 fun hours(seconds: Long): String { 116 val minutes = seconds / 60 117 return if (minutes >= 60) "${minutes / 60} h ${minutes % 60} min" else "$minutes min" 118 } 119 } 120 }