Skip to content

Commit b6aff04

Browse files
committed
fix: play youtube lives from the server live manifest
1 parent 7813696 commit b6aff04

5 files changed

Lines changed: 272 additions & 12 deletions

File tree

‎app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ internal fun resolveServerUrl(baseUrl: String, value: String?): String? {
1414
return resolved.takeIf { it.hasSameOrigin(server) }?.toString()
1515
}
1616

17+
internal fun resolvePlaybackUrl(baseUrl: String, value: String?): String? {
18+
val source = value?.takeIf { it.isNotBlank() } ?: return null
19+
return source.toHttpUrlOrNull()?.toString() ?: resolveServerUrl(baseUrl, source)
20+
}
21+
1722
internal fun resolveSabrPlaybackManifestUrl(
1823
baseUrl: String,
1924
value: String?,

‎app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,16 +40,19 @@ private fun Response<StreamResponse>.canFallbackToGeneric(): Boolean =
4040
internal fun StreamResponse.hasPlayableSabrContract(baseUrl: String? = null): Boolean {
4141
val playableVideoItags = (videoStreams + videoOnlyStreams).filter {
4242
it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 &&
43-
it.manifestUrl.isAllowedSabrManifest(baseUrl) && isServerSabrVideoFormat(it.codec)
43+
it.manifestUrl.isAllowedServerManifest(baseUrl) && isServerSabrVideoFormat(it.codec)
4444
}.mapTo(mutableSetOf()) { it.itag }
4545
return playableVideoItags.isNotEmpty() && audioStreams.any {
4646
it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 &&
47-
it.itag !in playableVideoItags && it.manifestUrl.isAllowedSabrManifest(baseUrl) &&
47+
it.itag !in playableVideoItags && it.manifestUrl.isAllowedServerManifest(baseUrl) &&
4848
isServerSabrAudioFormat(it.mimeType, it.codec)
4949
}
5050
}
5151

52-
private fun String?.isAllowedSabrManifest(baseUrl: String?): Boolean =
52+
internal fun StreamResponse.hasPlayableLiveContract(baseUrl: String? = null): Boolean =
53+
(isLive || hasLiveManifest) && hlsUrl.isAllowedServerManifest(baseUrl)
54+
55+
private fun String?.isAllowedServerManifest(baseUrl: String?): Boolean =
5356
!isNullOrBlank() && (baseUrl == null || resolveServerUrl(baseUrl, this) != null)
5457

5558
internal fun String.streamProvider(): StreamProvider {

‎app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,10 @@ internal class StreamRepositoryImpl @Inject constructor(
103103
}
104104
val body = response.body() ?: error("Empty stream body")
105105
activeAccountScope.verify(scope)
106-
if (provider == StreamProvider.YouTube && !body.hasPlayableSabrContract(server.baseUrl)) {
106+
if (provider == StreamProvider.YouTube &&
107+
!body.hasPlayableSabrContract(server.baseUrl) &&
108+
!body.hasPlayableLiveContract(server.baseUrl)
109+
) {
107110
throw SabrContractException()
108111
}
109112
body.toDomain(videoUrl, server.baseUrl, scope, provider)
@@ -115,7 +118,9 @@ internal class StreamRepositoryImpl @Inject constructor(
115118
scope: dev.typetype.android.data.account.AccountScope,
116119
provider: StreamProvider,
117120
): Stream {
118-
val serverSabr = provider == StreamProvider.YouTube
121+
val serverYoutube = provider == StreamProvider.YouTube
122+
val serverSabr = serverYoutube && hasPlayableSabrContract(baseUrl)
123+
val resolvedHlsUrl = resolvePlaybackUrl(baseUrl, hlsUrl).orEmpty()
119124
return Stream(
120125
playbackContract = if (serverSabr) {
121126
StreamPlaybackContract.ServerSabr
@@ -136,12 +141,12 @@ internal class StreamRepositoryImpl @Inject constructor(
136141
likeCount = likeCount,
137142
dislikeCount = dislikeCount,
138143
uploadedAtMillis = uploaded,
139-
hlsUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() },
144+
hlsUrl = resolvedHlsUrl.takeIf { !serverSabr && it.isNotBlank() },
140145
dashMpdUrl = dashMpdUrl.takeIf { !serverSabr && it.isNotBlank() },
141146
progressiveUrl = pickBestProgressiveStream(videoStreams).takeUnless { serverSabr },
142147
serverDashManifestUrl = serverManifestUrl(baseUrl, "streams/manifest", videoUrl)
143-
.takeUnless { serverSabr },
144-
serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() }
148+
.takeUnless { serverSabr || serverYoutube },
149+
serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && !serverYoutube && it.isNotBlank() }
145150
?.let { serverManifestUrl(baseUrl, "streams/hls-manifest", videoUrl) },
146151
serverSabrManifestUrl = resolveServerUrl(baseUrl, firstSabrManifestUrl()),
147152
sabrVideoStreams = (videoOnlyStreams + videoStreams)

‎app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt‎

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,38 @@ class StreamEndpointLoaderTest {
7373
assertEquals(1, server.requestCount)
7474
}
7575

76+
@Test
77+
fun youtubeLiveHlsResponseIsAcceptedAsLiveContract() = runBlocking {
78+
server.enqueue(
79+
jsonResponse(
80+
sabr = false,
81+
live = true,
82+
hlsUrl = "/streams/hls-manifest?token=signed",
83+
),
84+
)
85+
86+
val body = api.loadStreamResponse(YOUTUBE_URL).body()
87+
88+
assertFalse(body?.hasPlayableSabrContract(server.url("/").toString()) == true)
89+
assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true)
90+
assertEquals(1, server.requestCount)
91+
}
92+
93+
@Test
94+
fun youtubeLiveHlsResponseOnAnotherOriginIsRejected() = runBlocking {
95+
server.enqueue(
96+
jsonResponse(
97+
sabr = false,
98+
live = true,
99+
hlsUrl = "https://media.example/live.m3u8",
100+
),
101+
)
102+
103+
val body = api.loadStreamResponse(YOUTUBE_URL).body()
104+
105+
assertFalse(body?.hasPlayableLiveContract(server.url("/").toString()) == true)
106+
}
107+
76108
@Test
77109
fun youtubeSabrResponseWithoutValidItagsIsRejected() = runBlocking {
78110
server.enqueue(jsonResponse(sabr = true, videoItag = 0))
@@ -162,6 +194,8 @@ class StreamEndpointLoaderTest {
162194
private fun jsonResponse(
163195
sabr: Boolean,
164196
hls: Boolean = false,
197+
live: Boolean = false,
198+
hlsUrl: String? = null,
165199
videoItag: Int = 137,
166200
manifestUrl: String = "/sabr/manifest/video",
167201
videoCodec: String = "avc1.640028",
@@ -170,7 +204,18 @@ class StreamEndpointLoaderTest {
170204
): MockResponse = MockResponse()
171205
.setResponseCode(200)
172206
.setHeader("Content-Type", "application/json")
173-
.setBody(streamJson(sabr, hls, videoItag, manifestUrl, videoCodec, audioMimeType, audioCodec))
207+
.setBody(
208+
streamJson(
209+
sabr = sabr,
210+
live = live,
211+
hlsUrlValue = hlsUrl ?: if (hls) "https://media.example/live.m3u8" else "",
212+
videoItag = videoItag,
213+
manifestUrl = manifestUrl,
214+
videoCodec = videoCodec,
215+
audioMimeType = audioMimeType,
216+
audioCodec = audioCodec,
217+
),
218+
)
174219

175220
private fun errorResponse(code: Int): MockResponse = MockResponse()
176221
.setResponseCode(code)
@@ -179,7 +224,8 @@ class StreamEndpointLoaderTest {
179224

180225
private fun streamJson(
181226
sabr: Boolean,
182-
hls: Boolean,
227+
live: Boolean,
228+
hlsUrlValue: String,
183229
videoItag: Int,
184230
manifestUrl: String,
185231
videoCodec: String,
@@ -189,15 +235,15 @@ class StreamEndpointLoaderTest {
189235
val delivery = if (sabr) "sabr" else "progressive"
190236
val manifest = if (sabr) "\"$manifestUrl\"" else "null"
191237
val mediaUrl = if (sabr) "" else "https://media.example/video.mp4"
192-
val hlsUrl = if (hls) "https://media.example/live.m3u8" else ""
193238
return """
194239
{
195240
"id":"video","title":"Video","uploaderName":"Channel","uploaderUrl":"/channel",
196241
"uploaderAvatarUrl":"","thumbnailUrl":"","description":"","duration":60,
197242
"viewCount":1,"likeCount":0,"dislikeCount":0,"uploadDate":"","uploaded":-1,
198243
"uploaderSubscriberCount":0,"uploaderVerified":false,"category":"","license":"",
199244
"visibility":"public","streamType":"VIDEO_STREAM","isShortFormContent":false,
200-
"requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrl","dashMpdUrl":"",
245+
"requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrlValue","dashMpdUrl":"",
246+
"isLive":$live,"hasLiveManifest":$live,
201247
"videoStreams":[],
202248
"videoOnlyStreams":[{"url":"$mediaUrl","mimeType":"video/mp4","format":"MPEG_4",
203249
"resolution":"1080p","codec":"$videoCodec","isVideoOnly":true,"itag":$videoItag,
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
package dev.typetype.android.data.stream
2+
3+
import dev.typetype.android.core.error.CodedFailure
4+
import dev.typetype.android.data.account.AccountScope
5+
import dev.typetype.android.data.account.AccountScopeProvider
6+
import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver
7+
import dev.typetype.android.data.network.dto.AudioStreamItem
8+
import dev.typetype.android.data.network.dto.StreamResponse
9+
import dev.typetype.android.data.network.dto.VideoStreamItem
10+
import dev.typetype.android.domain.server.Server
11+
import dev.typetype.android.domain.server.ServerRepository
12+
import dev.typetype.android.domain.stream.StreamPlaybackContract
13+
import kotlinx.coroutines.flow.Flow
14+
import kotlinx.coroutines.flow.flowOf
15+
import kotlinx.coroutines.runBlocking
16+
import org.junit.Assert.assertEquals
17+
import org.junit.Assert.assertNull
18+
import org.junit.Assert.assertTrue
19+
import org.junit.Test
20+
import retrofit2.Response
21+
22+
class StreamRepositoryLiveTest {
23+
@Test
24+
fun `youtube live hls response resolves the signed server manifest`() = runBlocking {
25+
val repository = repository(
26+
response(
27+
isLive = true,
28+
hasLiveManifest = true,
29+
hlsUrl = LIVE_HLS_URL,
30+
),
31+
)
32+
33+
val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow()
34+
35+
assertEquals(StreamPlaybackContract.ProviderMedia, stream.playbackContract)
36+
assertEquals("$BASE_URL$LIVE_HLS_PATH", stream.hlsUrl)
37+
assertNull(stream.serverSabrManifestUrl)
38+
assertNull(stream.serverHlsManifestUrl)
39+
assertNull(stream.serverDashManifestUrl)
40+
assertTrue(stream.isLive)
41+
assertTrue(stream.sabrVideoStreams.isEmpty())
42+
}
43+
44+
@Test
45+
fun `youtube live response without a server manifest is rejected`() = runBlocking {
46+
val repository = repository(
47+
response(
48+
isLive = true,
49+
hasLiveManifest = true,
50+
hlsUrl = "https://media.example/live.m3u8",
51+
),
52+
)
53+
54+
val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull()
55+
56+
assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode)
57+
}
58+
59+
@Test
60+
fun `youtube video without sabr or live manifest is rejected`() = runBlocking {
61+
val repository = repository(response(isLive = false, hasLiveManifest = false, hlsUrl = ""))
62+
63+
val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull()
64+
65+
assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode)
66+
}
67+
68+
@Test
69+
fun `youtube vod keeps the sabr contract and drops provider manifests`() = runBlocking {
70+
val repository = repository(
71+
response(
72+
isLive = false,
73+
hasLiveManifest = false,
74+
hlsUrl = "",
75+
sabrFormats = true,
76+
),
77+
)
78+
79+
val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow()
80+
81+
assertEquals(StreamPlaybackContract.ServerSabr, stream.playbackContract)
82+
assertNull(stream.hlsUrl)
83+
assertEquals("$BASE_URL${SABR_MANIFEST_PATH.substringAfter('/')}", stream.serverSabrManifestUrl)
84+
}
85+
86+
private fun repository(response: StreamResponse) = StreamRepositoryImpl(
87+
remoteSource = FixedStreamRemoteSource(response),
88+
activeAccountScope = FixedAccountScope,
89+
serverRepository = FixedServerRepository,
90+
networkMonitor = AlwaysAvailablePlaybackNetworkObserver,
91+
)
92+
93+
private class FixedStreamRemoteSource(
94+
private val response: StreamResponse,
95+
) : StreamRemoteSource {
96+
override suspend fun load(
97+
scope: AccountScope,
98+
videoUrl: String,
99+
provider: StreamProvider,
100+
playbackBootstrap: Boolean,
101+
): Response<StreamResponse> = Response.success(response)
102+
}
103+
104+
private object FixedAccountScope : AccountScopeProvider {
105+
private val scope = AccountScope(SERVER_ID, ACCOUNT_ID)
106+
107+
override fun observe(): Flow<AccountScope?> = flowOf(scope)
108+
109+
override suspend fun require(): AccountScope = scope
110+
111+
override suspend fun verify(expected: AccountScope) {
112+
check(expected == scope)
113+
}
114+
}
115+
116+
private object FixedServerRepository : ServerRepository {
117+
private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L)
118+
119+
override fun observeServers(): Flow<List<Server>> = flowOf(listOf(server))
120+
override fun observeCurrentServer(): Flow<Server?> = flowOf(server)
121+
override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID }
122+
override suspend fun addServer(server: Server) = Unit
123+
override suspend fun deleteServer(id: String) = Unit
124+
override suspend fun setCurrentServer(id: String) = Unit
125+
override suspend fun clearCurrentServer() = Unit
126+
}
127+
128+
private fun response(
129+
isLive: Boolean,
130+
hasLiveManifest: Boolean,
131+
hlsUrl: String,
132+
sabrFormats: Boolean = false,
133+
) = StreamResponse(
134+
id = "video",
135+
title = "Video",
136+
uploaderName = "Channel",
137+
uploaderUrl = "/channel",
138+
uploaderAvatarUrl = "",
139+
thumbnailUrl = "",
140+
description = "",
141+
duration = if (isLive) 0L else 60L,
142+
viewCount = 1L,
143+
likeCount = 0L,
144+
dislikeCount = 0L,
145+
uploadDate = "",
146+
uploaded = 0L,
147+
uploaderSubscriberCount = 1L,
148+
uploaderVerified = false,
149+
category = "",
150+
license = "",
151+
visibility = "public",
152+
streamType = if (isLive) "LIVE_STREAM" else "VIDEO_STREAM",
153+
isShortFormContent = false,
154+
requiresMembership = false,
155+
isLive = isLive,
156+
hasLiveManifest = hasLiveManifest,
157+
startPosition = 0L,
158+
hlsUrl = hlsUrl,
159+
dashMpdUrl = "",
160+
videoOnlyStreams = if (sabrFormats) listOf(videoStream()) else emptyList(),
161+
audioStreams = if (sabrFormats) listOf(audioStream()) else emptyList(),
162+
)
163+
164+
private fun videoStream() = VideoStreamItem(
165+
url = "",
166+
mimeType = "video/mp4",
167+
format = "MPEG_4",
168+
resolution = "720p",
169+
codec = "avc1.64001f",
170+
isVideoOnly = true,
171+
itag = 136,
172+
width = 1280,
173+
height = 720,
174+
fps = 30,
175+
contentLength = 1L,
176+
deliveryMethod = "sabr",
177+
manifestUrl = SABR_MANIFEST_PATH,
178+
)
179+
180+
private fun audioStream() = AudioStreamItem(
181+
url = "",
182+
mimeType = "audio/mp4",
183+
format = "MPEG_4",
184+
codec = "mp4a.40.2",
185+
itag = 140,
186+
contentLength = 1L,
187+
isOriginal = true,
188+
deliveryMethod = "sabr",
189+
manifestUrl = SABR_MANIFEST_PATH,
190+
)
191+
192+
private companion object {
193+
const val SERVER_ID = "server"
194+
const val ACCOUNT_ID = "account"
195+
const val BASE_URL = "https://instance.example/api/"
196+
const val VIDEO_URL = "https://www.youtube.com/watch?v=video"
197+
const val SABR_MANIFEST_PATH = "/sabr/manifest/video"
198+
const val LIVE_HLS_PATH = "streams/hls-manifest?token=signed"
199+
const val LIVE_HLS_URL = "/$LIVE_HLS_PATH"
200+
}
201+
}

0 commit comments

Comments
 (0)