Skip to content

Commit 12b3067

Browse files
committed
fix: keep subtitle content on token egress
1 parent 9341522 commit 12b3067

3 files changed

Lines changed: 122 additions & 36 deletions

File tree

src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import dev.typetype.server.services.YouTubeSubtitleCache
3838
import dev.typetype.server.services.YouTubeSubtitleDeliveryService
3939
import dev.typetype.server.services.OkHttpYouTubeSubtitleContentFetcher
4040
import dev.typetype.server.services.StreamYouTubeSubtitleResolver
41+
import dev.typetype.server.services.TokenYouTubeSubtitleContentFetcher
4142
import dev.typetype.server.services.YoutubePlayerClient
4243
import dev.typetype.server.services.YoutubePlayerClientFallbackStreamService
4344
import dev.typetype.server.services.YoutubePlayerClientStreamService
@@ -117,7 +118,11 @@ internal class ExtractionServiceRegistry(
117118
)
118119
val youtubeSubtitleDeliveryService = YouTubeSubtitleDeliveryService(
119120
StreamYouTubeSubtitleResolver(youtubeSabrStreamService, youtubeSubtitleService::fetchSubtitleInventory),
120-
OkHttpYouTubeSubtitleContentFetcher(httpClient),
121+
TokenYouTubeSubtitleContentFetcher(
122+
httpClient,
123+
subtitleServiceUrl,
124+
OkHttpYouTubeSubtitleContentFetcher(httpClient),
125+
),
121126
YouTubeSubtitleCache(cache),
122127
)
123128
val youtubeSabrBootstrapStreamService = CachedStreamService(

src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt

Lines changed: 54 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import dev.typetype.server.currentRequestId
55
import kotlinx.coroutines.suspendCancellableCoroutine
66
import okhttp3.Call
77
import okhttp3.Callback
8+
import okhttp3.HttpUrl.Companion.toHttpUrl
89
import okhttp3.OkHttpClient
910
import okhttp3.Request
1011
import okhttp3.Response
@@ -28,51 +29,69 @@ internal class OkHttpYouTubeSubtitleContentFetcher(
2829
.header("User-Agent", OkHttpProxyService.BROWSER_USER_AGENT)
2930
.apply { currentRequestId()?.let { header(REQUEST_ID_HEADER, it) } }
3031
.build()
31-
return execute(request, format)
32+
return client.executeSubtitleRequest(request, format)
3233
}
34+
}
3335

34-
private suspend fun execute(
35-
request: Request,
36-
format: YouTubeSubtitleFormat,
37-
): YouTubeSubtitleFetchResult = suspendCancellableCoroutine { continuation ->
38-
val call = client.newCall(request)
39-
continuation.invokeOnCancellation { call.cancel() }
40-
call.enqueue(object : Callback {
41-
override fun onFailure(call: Call, e: IOException) {
42-
if (continuation.isActive) continuation.resume(YouTubeSubtitleFetchResult.Unavailable)
43-
}
36+
internal class TokenYouTubeSubtitleContentFetcher(
37+
private val client: OkHttpClient,
38+
baseUrl: String,
39+
private val directFetcher: YouTubeSubtitleContentFetcher,
40+
) : YouTubeSubtitleContentFetcher {
41+
private val endpoint = baseUrl.toHttpUrl().newBuilder()
42+
.addPathSegments("subtitles/content")
43+
.build()
4444

45-
override fun onResponse(call: Call, response: Response) {
46-
val result = runCatching { response.use { readResponse(it, format) } }
47-
.getOrDefault(YouTubeSubtitleFetchResult.Unavailable)
48-
if (continuation.isActive) continuation.resume(result)
49-
}
50-
})
45+
override suspend fun fetch(url: String, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult {
46+
if (format == YouTubeSubtitleFormat.Ttml) return directFetcher.fetch(url, format)
47+
if (!isYouTubeTimedTextUrl(url)) return YouTubeSubtitleFetchResult.Unavailable
48+
val request = Request.Builder()
49+
.url(endpoint.newBuilder().addQueryParameter("url", url).build())
50+
.header("Accept", format.contentType)
51+
.apply { currentRequestId()?.let { header(REQUEST_ID_HEADER, it) } }
52+
.build()
53+
return client.executeSubtitleRequest(request, format)
5154
}
55+
}
5256

53-
private fun readResponse(response: Response, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult {
54-
if (response.code == 429) return YouTubeSubtitleFetchResult.Throttled
55-
if (response.code == 403 || response.code == 404 || response.code == 410) {
56-
return YouTubeSubtitleFetchResult.Expired
57+
private suspend fun OkHttpClient.executeSubtitleRequest(
58+
request: Request,
59+
format: YouTubeSubtitleFormat,
60+
): YouTubeSubtitleFetchResult = suspendCancellableCoroutine { continuation ->
61+
val call = newCall(request)
62+
continuation.invokeOnCancellation { call.cancel() }
63+
call.enqueue(object : Callback {
64+
override fun onFailure(call: Call, e: IOException) {
65+
if (continuation.isActive) continuation.resume(YouTubeSubtitleFetchResult.Unavailable)
5766
}
58-
if (!response.isSuccessful) return YouTubeSubtitleFetchResult.Unavailable
59-
val body = response.body
60-
if (body.contentLength() > MAX_SUBTITLE_BYTES) return YouTubeSubtitleFetchResult.InvalidPayload
61-
return runCatching { body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) }
62-
.fold(
63-
onSuccess = { bytes ->
64-
if (isValidSubtitlePayload(bytes, format)) YouTubeSubtitleFetchResult.Ready(bytes)
65-
else YouTubeSubtitleFetchResult.InvalidPayload
66-
},
67-
onFailure = { YouTubeSubtitleFetchResult.Unavailable },
68-
)
69-
}
7067

71-
private companion object {
72-
const val YOUTUBE_ORIGIN = "https://m.youtube.com"
68+
override fun onResponse(call: Call, response: Response) {
69+
val result = runCatching { response.use { readSubtitleResponse(it, format) } }
70+
.getOrDefault(YouTubeSubtitleFetchResult.Unavailable)
71+
if (continuation.isActive) continuation.resume(result)
72+
}
73+
})
74+
}
75+
76+
private fun readSubtitleResponse(response: Response, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult {
77+
if (response.code == 429) return YouTubeSubtitleFetchResult.Throttled
78+
if (response.code == 403 || response.code == 404 || response.code == 410) {
79+
return YouTubeSubtitleFetchResult.Expired
7380
}
81+
if (!response.isSuccessful) return YouTubeSubtitleFetchResult.Unavailable
82+
val body = response.body
83+
if (body.contentLength() > MAX_SUBTITLE_BYTES) return YouTubeSubtitleFetchResult.InvalidPayload
84+
return runCatching { body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) }
85+
.fold(
86+
onSuccess = { bytes ->
87+
if (isValidSubtitlePayload(bytes, format)) YouTubeSubtitleFetchResult.Ready(bytes)
88+
else YouTubeSubtitleFetchResult.InvalidPayload
89+
},
90+
onFailure = { YouTubeSubtitleFetchResult.Unavailable },
91+
)
7492
}
7593

94+
private const val YOUTUBE_ORIGIN = "https://m.youtube.com"
7695
internal const val MAX_SUBTITLE_BYTES = 5 * 1024 * 1024
7796

7897
internal fun isValidSubtitlePayload(content: ByteArray, format: YouTubeSubtitleFormat): Boolean {

src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,45 @@ class YouTubeSubtitleContentFetcherTest {
4848
)
4949
}
5050

51+
@Test
52+
fun `token fetcher keeps WebVTT retrieval on the token service`() = runTest {
53+
var request: okhttp3.Request? = null
54+
val fetcher = tokenFetcher(VTT, observe = { request = it })
55+
56+
val result = fetcher.fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt)
57+
58+
assertTrue(result is YouTubeSubtitleFetchResult.Ready)
59+
assertEquals("token", request?.url?.host)
60+
assertEquals("/subtitles/content", request?.url?.encodedPath)
61+
assertEquals(TIMED_TEXT_URL, request?.url?.queryParameter("url"))
62+
}
63+
64+
@Test
65+
fun `token fetcher preserves typed throttling`() = runTest {
66+
val result = tokenFetcher("throttled", code = 429)
67+
.fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt)
68+
69+
assertEquals(YouTubeSubtitleFetchResult.Throttled, result)
70+
}
71+
72+
@Test
73+
fun `token fetcher leaves TTML on the direct path`() = runTest {
74+
var directFormat: YouTubeSubtitleFormat? = null
75+
val fetcher = tokenFetcher(
76+
body = VTT,
77+
observe = { error("Token must not receive TTML requests") },
78+
direct = { _, format ->
79+
directFormat = format
80+
YouTubeSubtitleFetchResult.Ready(TTML.encodeToByteArray())
81+
},
82+
)
83+
84+
val result = fetcher.fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Ttml)
85+
86+
assertTrue(result is YouTubeSubtitleFetchResult.Ready)
87+
assertEquals(YouTubeSubtitleFormat.Ttml, directFormat)
88+
}
89+
5190
private fun fetcher(
5291
body: String,
5392
contentType: String = "application/json",
@@ -69,8 +108,31 @@ class YouTubeSubtitleContentFetcherTest {
69108
return OkHttpYouTubeSubtitleContentFetcher(client)
70109
}
71110

111+
private fun tokenFetcher(
112+
body: String,
113+
code: Int = 200,
114+
observe: (okhttp3.Request) -> Unit = {},
115+
direct: suspend (String, YouTubeSubtitleFormat) -> YouTubeSubtitleFetchResult =
116+
{ _, _ -> YouTubeSubtitleFetchResult.Unavailable },
117+
): TokenYouTubeSubtitleContentFetcher {
118+
val client = OkHttpClient.Builder()
119+
.addInterceptor { chain ->
120+
observe(chain.request())
121+
Response.Builder()
122+
.request(chain.request())
123+
.protocol(Protocol.HTTP_1_1)
124+
.code(code)
125+
.message("test")
126+
.body(body.toResponseBody("text/vtt".toMediaType()))
127+
.build()
128+
}
129+
.build()
130+
return TokenYouTubeSubtitleContentFetcher(client, "http://token/", YouTubeSubtitleContentFetcher(direct))
131+
}
132+
72133
private companion object {
73134
const val VTT = "WEBVTT\n\n00:00.000 --> 00:01.000\nHello"
135+
const val TTML = "<tt><body></body></tt>"
74136
const val TIMED_TEXT_URL = "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&fmt=vtt"
75137
}
76138
}

0 commit comments

Comments
 (0)