Skip to content
This repository has been archived by the owner on Feb 1, 2023. It is now read-only.

Commit

Permalink
✨ Use Twitch as streaming backend
Browse files Browse the repository at this point in the history
  • Loading branch information
markhaehnel committed Jun 19, 2021
1 parent 3f7a957 commit bdac093
Show file tree
Hide file tree
Showing 13 changed files with 165 additions and 92 deletions.
14 changes: 7 additions & 7 deletions app/build.gradle
Expand Up @@ -48,7 +48,7 @@ dependencies {
implementation platform("com.google.firebase:firebase-bom:$firebase_version")
implementation 'com.google.firebase:firebase-crashlytics-ktx'

def dagger_version = "2.28.3"
def dagger_version = "2.35.1"
implementation "com.google.dagger:dagger:$dagger_version"
implementation "com.google.dagger:dagger-android:$dagger_version"
implementation "com.google.dagger:dagger-android-support:$dagger_version"
Expand All @@ -63,13 +63,13 @@ dependencies {
def timber_version = "4.7.1"
implementation "com.jakewharton.timber:timber:$timber_version"

def support_version = "1.2.0"
def support_version = "1.3.0"
implementation "androidx.appcompat:appcompat:$support_version"

def contstraint_version = "2.0.1"
def contstraint_version = "2.0.4"
implementation "androidx.constraintlayout:constraintlayout:$contstraint_version"

def ktx_version = "1.5.0-alpha02"
def ktx_version = "1.6.0-rc01"
implementation "androidx.core:core-ktx:$ktx_version"

def lifecycle_version = "2.2.0"
Expand All @@ -82,7 +82,7 @@ dependencies {

implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"

def fragment_version = "1.3.0-alpha08"
def fragment_version = "1.4.0-alpha03"
implementation "androidx.fragment:fragment-ktx:$fragment_version"

implementation "androidx.navigation:navigation-ui-ktx:$navigation_version"
Expand All @@ -94,15 +94,15 @@ dependencies {
def m3u8parser_version = "0.6"
implementation "io.lindstrom:m3u8-parser:$m3u8parser_version"

def recycler_version = "1.1.0"
def recycler_version = "1.2.1"
implementation "androidx.recyclerview:recyclerview:$recycler_version"

def socket_version = "1.0.0"
implementation ("io.socket:socket.io-client:$socket_version") {
exclude group: "org.json", module: "json"
}

def gson_version = "2.8.5"
def gson_version = "2.8.6"
implementation "com.google.code.gson:gson:$gson_version"


Expand Down
@@ -0,0 +1,17 @@
package de.markhaehnel.rbtv.rocketbeanstv.api

import androidx.lifecycle.LiveData
import de.markhaehnel.rbtv.rocketbeanstv.util.Constants
import de.markhaehnel.rbtv.rocketbeanstv.vo.TwitchAccesToken
import de.markhaehnel.rbtv.rocketbeanstv.vo.TwitchGraphQLAccessTokenBody
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*

interface TwitchGraphQLService {
@POST("gql")
@Headers("Client-ID: ${Constants.TWITCH_CLIENT_ID}")
fun getAccessToken(
@Body body: TwitchGraphQLAccessTokenBody = TwitchGraphQLAccessTokenBody()
): LiveData<ApiResponse<TwitchAccesToken>>
}
@@ -0,0 +1,16 @@
package de.markhaehnel.rbtv.rocketbeanstv.api

import androidx.lifecycle.LiveData
import de.markhaehnel.rbtv.rocketbeanstv.util.Constants
import de.markhaehnel.rbtv.rocketbeanstv.vo.TwitchAccesToken
import okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*

interface TwitchUsherService {
@GET("api/channel/hls/${Constants.TWITCH_CHANNEL}.m3u8?allow_source=true&allow_audio_only=false&client_id=${Constants.TWITCH_CLIENT_ID}")
fun getPlaylist(
@Query("token") token: String,
@Query("sig") signature: String
): Call<ResponseBody>
}

This file was deleted.

Expand Up @@ -4,8 +4,7 @@ import com.google.gson.GsonBuilder
import dagger.Module
import dagger.Provides
import de.markhaehnel.rbtv.rocketbeanstv.BuildConfig
import de.markhaehnel.rbtv.rocketbeanstv.api.RbtvService
import de.markhaehnel.rbtv.rocketbeanstv.api.YouTubeService
import de.markhaehnel.rbtv.rocketbeanstv.api.*
import de.markhaehnel.rbtv.rocketbeanstv.repository.ChatRepository
import de.markhaehnel.rbtv.rocketbeanstv.util.LiveDataCallAdapterFactory
import de.markhaehnel.rbtv.rocketbeanstv.util.UserAgentInterceptor
Expand Down Expand Up @@ -39,13 +38,24 @@ class AppModule {

@Singleton
@Provides
fun provideYouTubeService(): YouTubeService {
fun provideTwitchGraphQLService(): TwitchGraphQLService {
return Retrofit.Builder()
.baseUrl("https://www.youtube.com/")
.baseUrl("https://gql.twitch.tv/")
.addConverterFactory(GsonConverterFactory.create())
.addCallAdapterFactory(LiveDataCallAdapterFactory())
.build()
.create(TwitchGraphQLService::class.java)
}

@Singleton
@Provides
fun provideTwitchUsherService(): TwitchUsherService {
return Retrofit.Builder()
.baseUrl("https://usher.ttvnw.net/")
.addConverterFactory(ScalarsConverterFactory.create())
.addCallAdapterFactory(LiveDataCallAdapterFactory())
.build()
.create(YouTubeService::class.java)
.create(TwitchUsherService::class.java)
}

@Singleton
Expand Down
Expand Up @@ -5,8 +5,7 @@ import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.google.gson.Gson
import de.markhaehnel.rbtv.rocketbeanstv.AppExecutors
import de.markhaehnel.rbtv.rocketbeanstv.api.RbtvService
import de.markhaehnel.rbtv.rocketbeanstv.api.YouTubeService
import de.markhaehnel.rbtv.rocketbeanstv.api.*
import de.markhaehnel.rbtv.rocketbeanstv.vo.*
import io.lindstrom.m3u8.model.MasterPlaylist
import io.lindstrom.m3u8.parser.MasterPlaylistParser
Expand All @@ -23,7 +22,8 @@ import javax.inject.Singleton
class StreamRepository @Inject constructor(
private val appExecutors: AppExecutors,
private val rbtvService: RbtvService,
private val youTubeService: YouTubeService
private val twitchGraphQLService: TwitchGraphQLService,
private val twitchUsherService: TwitchUsherService
) {
fun loadServiceInfo(): LiveData<Resource<RbtvServiceInfo>> {
return object : NetworkBoundResource<RbtvServiceInfo>(appExecutors) {
Expand All @@ -37,60 +37,29 @@ class StreamRepository @Inject constructor(
}.asLiveData()
}

fun loadStreamManifest(videoId: String): LiveData<Resource<StreamManifest>> {
//TODO: move this to NetworkBoundResource
val data = MutableLiveData<Resource<StreamManifest>>()
data.value = Resource.loading(null)

//TODO: refactor this into a custom retrofit converter
youTubeService.getVideoInfo(videoId).enqueue(object : Callback<ResponseBody> {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
try {
val responseString = response.body()?.string()

if (responseString != null) {
val parameters = HashMap<String, String>()
for (param in responseString.split(Pattern.quote("&").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) {
val line =
param.split(Pattern.quote("=").toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
if (line.size == 2)
parameters.put(line[0], URLDecoder.decode(line[1], "UTF-8"))
}

val gson = Gson()
val playerResponse = gson.fromJson(parameters["player_response"], PlayerResponse::class.java)

val dataRaw = StreamManifest(playerResponse.streamingData.hlsManifestUrl.toUri())
data.value = Resource.success(dataRaw)
} else {
data.value = Resource.error("Error: Stream manifest is empty")
}
} catch (e: Exception) {
data.value = Resource.error("Error while fetching stream manifest")
}
}

override fun onFailure(call: Call<ResponseBody>?, t: Throwable?) {
data.value = Resource.error(t.toString())
}
})

return data
fun loadAccessToken(): LiveData<Resource<TwitchAccesToken>> {
return object : NetworkBoundResource<TwitchAccesToken>(appExecutors) {
override fun createCall() = twitchGraphQLService.getAccessToken()
}.asLiveData()
}

fun loadPlaylist(playlistUrl: String): LiveData<Resource<MasterPlaylist>> {
fun loadPlaylist(token: String, signature: String): LiveData<Resource<MasterPlaylist>> {
val data = MutableLiveData<Resource<MasterPlaylist>>()
data.value = Resource.loading(null)

//TODO: refactor this into a custom retrofit converter
youTubeService.getPlaylist(playlistUrl).enqueue(object : Callback<ResponseBody> {
twitchUsherService.getPlaylist(token, signature).enqueue(object : Callback<ResponseBody> {
override fun onResponse(call: Call<ResponseBody>, response: Response<ResponseBody>) {
try {
var responseBody = response.body()?.string()

if (!responseBody.isNullOrBlank()) {
// Hotfix for playlist parser not understanding video-range attribute
responseBody = responseBody.replace(",VIDEO-RANGE=SDR", "")
// Hotfix for playlist parser not understanding #EXT-X-TWITCH-INFO attribute
val regex = "^#EXT-X-TWITCH-INFO.*$\n".toRegex(RegexOption.MULTILINE)
responseBody = responseBody.replace(regex, "")



val playlist = MasterPlaylistParser().readPlaylist(responseBody)
data.value = Resource.success(playlist)
} else {
Expand Down
Expand Up @@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModel
import de.markhaehnel.rbtv.rocketbeanstv.repository.StreamRepository
import de.markhaehnel.rbtv.rocketbeanstv.util.AbsentLiveData
import de.markhaehnel.rbtv.rocketbeanstv.vo.Resource
import de.markhaehnel.rbtv.rocketbeanstv.vo.StreamManifest
import de.markhaehnel.rbtv.rocketbeanstv.vo.TwitchAccesToken
import io.lindstrom.m3u8.model.MasterPlaylist
import javax.inject.Inject

Expand All @@ -18,21 +18,22 @@ class PlayerViewModel

private var rbtvServiceInfo = streamRepository.loadServiceInfo()

private var streamManifest: LiveData<Resource<StreamManifest>> = Transformations
private var twitchAccessToken: LiveData<Resource<TwitchAccesToken>> = Transformations
.switchMap(rbtvServiceInfo) { serviceInfo ->
if (serviceInfo.data === null) {
AbsentLiveData.create()
} else {
streamRepository.loadStreamManifest(serviceInfo.data.service.streamInfo.youtubeToken)
streamRepository.loadAccessToken()
}
}

var streamPlaylist: LiveData<Resource<MasterPlaylist>> = Transformations
.switchMap(streamManifest) { manifest ->
if (manifest === null || manifest.data === null) {
.switchMap(twitchAccessToken) { accessToken ->
if (accessToken === null || accessToken.data === null) {
AbsentLiveData.create()
} else {
streamRepository.loadPlaylist(manifest.data.hlsUri.toString())
val streamPlaybackAccessToken = accessToken.data.data.streamPlaybackAccessToken
streamRepository.loadPlaylist(streamPlaybackAccessToken.value, streamPlaybackAccessToken.signature)
}
}

Expand Down
Expand Up @@ -4,5 +4,7 @@ class Constants {
companion object {
const val BROADCAST_KEYDOWN = "BROADCAST_KEYDOWN"
const val BROADCAST_KEYDOWN_KEY_CODE = "BROADCAST_KEYDOWN_KEY_CODE"
const val TWITCH_CLIENT_ID = "kimne78kx3ncx6brgo4mv6wki5h1ko"
const val TWITCH_CHANNEL = "rocketbeanstv"
}
}

This file was deleted.

@@ -0,0 +1,30 @@
package de.markhaehnel.rbtv.rocketbeanstv.vo

/*{
"data": {
"streamPlaybackAccessToken": {
"value": "{\"adblock\":false,\"authorization\":{\"forbidden\":false,\"reason\":\"\"},\"blackout_enabled\":false,\"channel\":\"rocketbeanstv\",\"channel_id\":47627824,\"chansub\":{\"restricted_bitrates\":[],\"view_until\":1924905600},\"ci_gb\":false,\"geoblock_reason\":\"\",\"device_id\":null,\"expires\":1624101638,\"extended_history_allowed\":false,\"game\":\"\",\"hide_ads\":false,\"https_required\":true,\"mature\":false,\"partner\":false,\"platform\":\"web\",\"player_type\":\"embed\",\"private\":{\"allowed_to_view\":true},\"privileged\":false,\"role\":\"\",\"server_ads\":true,\"show_ads\":true,\"subscriber\":false,\"turbo\":false,\"user_id\":null,\"user_ip\":\"84.119.130.17\",\"version\":2}",
"signature": "34fb8614e66d04a68dc716915740b0f2c3d676cc",
"__typename": "PlaybackAccessToken"
}
},
"extensions": {
"durationMilliseconds": 55,
"operationName": "PlaybackAccessToken",
"requestID": "01F8HYW0EWR1PYQN3WVSAZWMJD"
}
}*/


data class TwitchAccesToken(
val data: TwitchAccesTokenData
)

data class TwitchAccesTokenData(
val streamPlaybackAccessToken: TwitchStreamPlaybackAccessToken
)

data class TwitchStreamPlaybackAccessToken(
val value: String,
val signature: String
)
@@ -0,0 +1,43 @@
package de.markhaehnel.rbtv.rocketbeanstv.vo

/*{
"operationName": "PlaybackAccessToken",
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712"
}
},
"variables": {
"isLive": true,
"login": "rocketbeanstv",
"isVod": false,
"vodID": "",
"playerType": "embed"
}
}
*/


data class TwitchGraphQLAccessTokenBody(
val operationName: String = "PlaybackAccessToken",
val extensions: TwitchGraphQLAccessTokenBodyExtensions = TwitchGraphQLAccessTokenBodyExtensions(TwitchGraphQLAccessTokenBodyExtensionsPersistedQuery()),
val variables: TwitchGraphQLAccessTokenBodyVariables = TwitchGraphQLAccessTokenBodyVariables()
)

data class TwitchGraphQLAccessTokenBodyExtensions(
val persistedQuery: TwitchGraphQLAccessTokenBodyExtensionsPersistedQuery
)

data class TwitchGraphQLAccessTokenBodyExtensionsPersistedQuery(
val version: Int = 1,
val sha256Hash: String = "0828119ded1c13477966434e15800ff57ddacf13ba1911c129dc2200705b0712"
)

data class TwitchGraphQLAccessTokenBodyVariables(
val isLive: Boolean = true,
val login: String = "rocketbeanstv",
val isVod: Boolean = false,
val vodID: String = "",
val playerType: String = "embed"
)

0 comments on commit bdac093

Please sign in to comment.