diff --git a/app/build.gradle b/app/build.gradle index f87e2ab4f..83515e963 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -26,8 +26,8 @@ android { defaultConfig { applicationId "com.github.exact7.xtra" minSdkVersion 16 - versionCode 99 - versionName "1.5.6.7" + versionCode 100 + versionName "1.5.6.8" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" targetSdkVersion 29 javaCompileOptions { diff --git a/app/src/main/java/com/github/exact7/xtra/api/GraphQLApi.kt b/app/src/main/java/com/github/exact7/xtra/api/GraphQLApi.kt index d86c64bd3..30e0d82e3 100644 --- a/app/src/main/java/com/github/exact7/xtra/api/GraphQLApi.kt +++ b/app/src/main/java/com/github/exact7/xtra/api/GraphQLApi.kt @@ -8,6 +8,7 @@ import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.Header +import retrofit2.http.HeaderMap import retrofit2.http.POST @JvmSuppressWildcards @@ -23,8 +24,8 @@ interface GraphQLApi { suspend fun getChannelPanel(@Body json: JsonArray): Response @POST(".") - suspend fun getStreamAccessToken(@Header("Authorization") token: String, @Body json: JsonArray): StreamPlaylistTokenResponse + suspend fun getStreamAccessToken(@HeaderMap headers: Map, @Body json: JsonArray): StreamPlaylistTokenResponse @POST(".") - suspend fun getVideoAccessToken(@Header("Authorization") token: String, @Body json: JsonArray): VideoPlaylistTokenResponse + suspend fun getVideoAccessToken(@HeaderMap headers: Map, @Body json: JsonArray): VideoPlaylistTokenResponse } \ No newline at end of file diff --git a/app/src/main/java/com/github/exact7/xtra/repository/PlayerRepository.kt b/app/src/main/java/com/github/exact7/xtra/repository/PlayerRepository.kt index d2970434f..41950197d 100644 --- a/app/src/main/java/com/github/exact7/xtra/repository/PlayerRepository.kt +++ b/app/src/main/java/com/github/exact7/xtra/repository/PlayerRepository.kt @@ -27,12 +27,15 @@ import kotlinx.coroutines.withContext import okhttp3.ResponseBody import retrofit2.HttpException import retrofit2.Response +import java.util.* import javax.inject.Inject import javax.inject.Singleton +import kotlin.collections.HashMap import kotlin.collections.set import kotlin.random.Random private const val TAG = "PlayerRepository" +private const val UNDEFINED = "undefined" @Singleton class PlayerRepository @Inject constructor( @@ -52,53 +55,42 @@ class PlayerRepository @Inject constructor( // val apiToken = UUID.randomUUID().toString().replace("-", "").substring(0, 32) // val serverSessionId = UUID.randomUUID().toString().replace("-", "").substring(0, 32) // val cookie = "unique_id=$uniqueId; unique_id_durable=$uniqueId; twitch.lohp.countryCode=BY; api_token=twilight.$apiToken; server_session_id=$serverSessionId" - // val accessToken = api.getStreamAccessToken(clientId, cookie, channelName, token, playerType) - val array = JsonArray(1) - val streamAccessTokenOperation = JsonObject().apply { - addProperty("operationName", "PlaybackAccessToken") - add("variables", JsonObject().apply { - addProperty("isLive", true) - addProperty("isVod", false) - addProperty("login", channelName) - addProperty("playerType", playerType) - addProperty("vodID", "") - }) - add("extensions", JsonObject().apply { - add("persistedQuery", JsonObject().apply { - addProperty("version", 1) - addProperty("sha256Hash", "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712") - }) - }) + + val accessTokenJson = getAccessTokenJson(isLive = true, isVod = false, login = channelName, playerType = playerType, vodId = "") + val accessTokenHeaders = getAccessTokenHeaders() + + suspend fun loadStream(token: String): Uri { + accessTokenHeaders["Authorization"] = token + val accessToken = graphQL.getStreamAccessToken(accessTokenHeaders, accessTokenJson) + val playlistQueryOptions = HashMap() +// playlistQueryOptions["token"] = accessToken.token +// playlistQueryOptions["sig"] = accessToken.sig + playlistQueryOptions["token"] = accessToken.token + playlistQueryOptions["sig"] = accessToken.signature + playlistQueryOptions["allow_source"] = "true" + playlistQueryOptions["allow_audio_only"] = "true" + playlistQueryOptions["type"] = "any" + playlistQueryOptions["p"] = Random.nextInt(999999).toString() + playlistQueryOptions["fast_bread"] = "true" //low latency + + //not working anyway +// playlistQueryOptions["server_ads"] = "false" +// playlistQueryOptions["show_ads"] = "false" + val playlist = usher.getStreamPlaylist(channelName, playlistQueryOptions) + return playlist.raw().request().url().toString().toUri() } - array.add(streamAccessTokenOperation) val shuffled = tokenList.split(",").shuffled() for (token in shuffled) { try { - val accessToken = graphQL.getStreamAccessToken(TwitchApiHelper.addTokenPrefix(token), array) - val options = HashMap() -// options["token"] = accessToken.token -// options["sig"] = accessToken.sig - options["token"] = accessToken.token - options["sig"] = accessToken.signature - options["allow_source"] = "true" - options["allow_audio_only"] = "true" - options["type"] = "any" - options["p"] = Random.nextInt(999999).toString() - options["fast_bread"] = "true" //low latency - - //not working anyway -// options["server_ads"] = "false" -// options["show_ads"] = "false" - val playlist = usher.getStreamPlaylist(channelName, options) - return@withContext playlist.raw().request().url().toString().toUri() + return@withContext loadStream(TwitchApiHelper.addTokenPrefix(token)) } catch (e: HttpException) { if (e.code() != 401) throw e Log.e(TAG, "Token $token is expired") } } - throw Exception("Unable to load stream") + loadStream(UNDEFINED) } suspend fun loadVideoPlaylist(videoId: String, clientId: String, tokenList: String): Response = withContext(Dispatchers.IO) { @@ -106,45 +98,34 @@ class PlayerRepository @Inject constructor( Log.d(TAG, "Getting video playlist for video $id. Client id: $clientId") // val accessToken = api.getVideoAccessToken(clientId, id, token) - val array = JsonArray(1) - val videoAccessTokenOperation = JsonObject().apply { - addProperty("operationName", "PlaybackAccessToken") - add("variables", JsonObject().apply { - addProperty("isLive", false) - addProperty("isVod", true) - addProperty("login", "") - addProperty("playerType", "channel_home_live") - addProperty("vodID", id) - }) - add("extensions", JsonObject().apply { - add("persistedQuery", JsonObject().apply { - addProperty("version", 1) - addProperty("sha256Hash", "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712") - }) - }) + val accessTokenJson = getAccessTokenJson(isLive = false, isVod = true, login = "", playerType = "channel_home_live", vodId = id) + val accessTokenHeaders = getAccessTokenHeaders() + + suspend fun loadVideo(token: String): Response { + accessTokenHeaders["Authorization"] = token + val accessToken = graphQL.getVideoAccessToken(accessTokenHeaders, accessTokenJson) + val playlistQueryOptions = HashMap() +// options["token"] = accessToken.token +// options["sig"] = accessToken.sig + playlistQueryOptions["token"] = accessToken.token + playlistQueryOptions["sig"] = accessToken.signature + playlistQueryOptions["allow_source"] = "true" + playlistQueryOptions["allow_audio_only"] = "true" + playlistQueryOptions["type"] = "any" + playlistQueryOptions["p"] = Random.nextInt(999999).toString() + return usher.getVideoPlaylist(id, playlistQueryOptions) } - array.add(videoAccessTokenOperation) val shuffled = tokenList.split(",").shuffled() for (token in shuffled) { try { - val accessToken = graphQL.getVideoAccessToken(TwitchApiHelper.addTokenPrefix(token), array) - val options = HashMap() -// options["token"] = accessToken.token -// options["sig"] = accessToken.sig - options["token"] = accessToken.token - options["sig"] = accessToken.signature - options["allow_source"] = "true" - options["allow_audio_only"] = "true" - options["type"] = "any" - options["p"] = Random.nextInt(999999).toString() - return@withContext usher.getVideoPlaylist(id, options) + return@withContext loadVideo(TwitchApiHelper.addTokenPrefix(token)) } catch (e: HttpException) { if (e.code() != 401) throw e Log.e(TAG, "Token $token is expired") } } - throw Exception("Unable to load video") + loadVideo(UNDEFINED) } suspend fun loadSubscriberBadges(channelId: String): SubscriberBadgesResponse = withContext(Dispatchers.IO) { @@ -192,4 +173,46 @@ class PlayerRepository @Inject constructor( videoPositions.insert(position) } } -} + + private fun getAccessTokenJson(isLive: Boolean, isVod: Boolean, login: String, playerType: String, vodId: String): JsonArray { + val jsonArray = JsonArray(1) + val operation = JsonObject().apply { + addProperty("operationName", "PlaybackAccessToken") + add("variables", JsonObject().apply { + addProperty("isLive", isLive) + addProperty("isVod", isVod) + addProperty("login", login) + addProperty("playerType", playerType) + addProperty("vodID", vodId) + }) + add("extensions", JsonObject().apply { + add("persistedQuery", JsonObject().apply { + addProperty("version", 1) + addProperty("sha256Hash", "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712") + }) + }) + } + jsonArray.add(operation) + return jsonArray + } + + private fun getAccessTokenHeaders(): MutableMap { + return HashMap().apply { + put("X-Device-Id", UUID.randomUUID().toString().replace("-", "").substring(0, 32)) //removes "commercial break in progress" + put("Accept", "*/*") + put("Accept-Encoding", "gzip, deflate, br") + put("Accept-Language", "ru-RU") + put("Connection", "keep-alive") + put("Content-Type", "text/plain;charset=UTF-8") + put("Host", "gql.twitch.tv") + put("Origin", "https://www.twitch.tv") + put("Referer", "https://www.twitch.tv/") + put("sec-ch-ua", "\"Google Chrome\";v=\"87\", \" Not;A Brand\";v=\"99\", \"Chromium\";v=\"87\"") + put("sec-ch-ua-mobile", "?0") + put("Sec-Fetch-Dest", "empty") + put("Sec-Fetch-Mode", "cors") + put("Sec-Fetch-Site", "same-site") + put("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.141 Safari/537.36") + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 8780a9035..125255c36 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -128,7 +128,9 @@ شكراً جزيلاً!!! ❤❤❤ (GIF)الرموز التعبيرية المتحركة - January 23rd, 1.5.6.7 + January 24th, 1.5.6.8 +\n- Fixed stream errors (for now), but you may see more commercials + \n\nJanuary 23rd, 1.5.6.7 \n- Just trying to keep the app alive \n\nJanuary 14th, 1.5.6.6 \n- Updated API for streams and videos diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 95ab66d89..5f215df4f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -128,7 +128,9 @@ Огромное спасибо!!! ❤❤❤ Анимированные смайлики - 23 января, 1.5.6.7 + 24 января, 1.5.6.8 +\n- Исправлена ошибка загрузки стримов (пока твич что-то не изменит) + \n\n23 января, 1.5.6.7 \n- Просто пытаюсь продлить жизнь приложению \n\n14 января, 1.5.6.6 \n- Обновлен API для стримов и видео diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 52b116370..652c90988 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -132,7 +132,9 @@ Support the developer Animated emotes - January 23rd, 1.5.6.7 + January 24th, 1.5.6.8 +\n- Fixed stream errors (for now), but you may see more commercials + \n\nJanuary 23rd, 1.5.6.7 \n- Just trying to keep the app alive \n\nJanuary 14th, 1.5.6.6 \n- Updated API for streams and videos