Recently Written · git

subrep-android

Subrep on Android: live captions of the sound of the phone, made on the phone by Whisper, shared by link.

git clone https://github.com/equwal/subrep-android

Log | Files | Refs


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 }