|
| 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