2021-12-04 19:55:26 +01:00
package org.mosad.teapod.parser.crunchyroll
2021-12-05 00:42:56 +01:00
import android.util.Log
2021-12-04 19:55:26 +01:00
import com.github.kittinunf.fuel.Fuel
import com.github.kittinunf.fuel.core.FuelError
2021-12-05 00:42:56 +01:00
import com.github.kittinunf.fuel.core.Parameters
2022-01-02 22:39:31 +01:00
import com.github.kittinunf.fuel.core.extensions.jsonBody
2021-12-04 19:55:26 +01:00
import com.github.kittinunf.fuel.json.FuelJson
import com.github.kittinunf.fuel.json.responseJson
import com.github.kittinunf.result.Result
import kotlinx.coroutines.*
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
2022-01-02 22:39:31 +01:00
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
2021-12-28 20:32:44 +01:00
import org.mosad.teapod.preferences.Preferences
2021-12-20 22:14:58 +01:00
import java.util.*
2021-12-04 19:55:26 +01:00
private val json = Json { ignoreUnknownKeys = true }
2021-12-05 00:42:56 +01:00
object Crunchyroll {
2021-12-04 19:55:26 +01:00
2021-12-20 22:14:58 +01:00
private const val baseUrl = " https://beta-api.crunchyroll.com "
2021-12-04 19:55:26 +01:00
private var accessToken = " "
private var tokenType = " "
2022-01-02 22:39:31 +01:00
private var accountID = " "
2021-12-05 01:34:06 +01:00
private var policy = " "
private var signature = " "
private var keyPairID = " "
2021-12-20 22:14:58 +01:00
// TODO temp helper vary
2021-12-28 20:32:44 +01:00
private var locale : String = Preferences . preferredLocal . toLanguageTag ( )
private var country : String = Preferences . preferredLocal . country
2021-12-20 22:14:58 +01:00
2021-12-28 20:32:44 +01:00
private val browsingCache = arrayListOf < Item > ( )
2021-12-20 22:14:58 +01:00
2021-12-04 19:55:26 +01:00
fun login ( username : String , password : String ) : Boolean = runBlocking {
val tokenEndpoint = " /auth/v1/token "
val formData = listOf (
" username " to username ,
" password " to password ,
" grant_type " to " password " ,
" scope " to " offline_access "
)
withContext ( Dispatchers . IO ) {
val ( request , response , result ) = Fuel . post ( " $baseUrl $tokenEndpoint " , parameters = formData )
. header ( " Content-Type " , " application/x-www-form-urlencoded " )
. appendHeader (
" Authorization " ,
" Basic "
)
. responseJson ( )
2021-12-05 01:34:06 +01:00
// TODO fix JSONException: No value for
2021-12-04 19:55:26 +01:00
result . component1 ( ) ?. obj ( ) ?. let {
accessToken = it . get ( " access_token " ) . toString ( )
tokenType = it . get ( " token_type " ) . toString ( )
}
// println("request: $request")
// println("response: $response")
// println("response: $result")
2021-12-05 00:42:56 +01:00
Log . i ( javaClass . name , " login complete with code ${response.statusCode} " )
2021-12-04 19:55:26 +01:00
return @withContext response . statusCode == 200
}
return @runBlocking false
}
// TODO get/post difference
2021-12-20 22:14:58 +01:00
private suspend fun request (
endpoint : String ,
params : Parameters = listOf ( ) ,
url : String = " "
) : Result < FuelJson , FuelError > = coroutineScope {
val path = if ( url . isEmpty ( ) ) " $baseUrl $endpoint " else url
2021-12-31 16:03:15 +01:00
// TODO before sending a request, make sure the accessToken is not expired
2021-12-04 19:55:26 +01:00
return @coroutineScope ( Dispatchers . IO ) {
2021-12-20 22:14:58 +01:00
val ( request , response , result ) = Fuel . get ( path , params )
2021-12-04 19:55:26 +01:00
. header ( " Authorization " , " $tokenType $accessToken " )
. responseJson ( )
// println("request request: $request")
// println("request response: $response")
// println("request result: $result")
result
}
}
2022-01-02 22:39:31 +01:00
private suspend fun requestPost (
endpoint : String ,
params : Parameters = listOf ( ) ,
body : String
) = coroutineScope {
val path = " $baseUrl $endpoint "
// TODO before sending a request, make sure the accessToken is not expired
withContext ( Dispatchers . IO ) {
Fuel . post ( path , params )
. header ( " Authorization " , " $tokenType $accessToken " )
. jsonBody ( body )
. response ( ) // without a response, crunchy doesn't accept the request
}
}
private suspend fun requestDelete (
endpoint : String ,
params : Parameters = listOf ( ) ,
url : String = " "
) = coroutineScope {
val path = if ( url . isEmpty ( ) ) " $baseUrl $endpoint " else url
// TODO before sending a request, make sure the accessToken is not expired
withContext ( Dispatchers . IO ) {
Fuel . delete ( path , params )
. header ( " Authorization " , " $tokenType $accessToken " )
. response ( ) // without a response, crunchy doesn't accept the request
}
}
/ * *
* Basic functions : index , account
* Needed for other functions to work properly !
* /
2021-12-20 22:14:58 +01:00
/ * *
* Retrieve the identifiers necessary for streaming . If the identifiers are
* retrieved , set the corresponding global var . The identifiers are valid for 24 h .
* /
suspend fun index ( ) {
val indexEndpoint = " /index/v2 "
val result = request ( indexEndpoint )
result . component1 ( ) ?. obj ( ) ?. getJSONObject ( " cms " ) ?. let {
policy = it . get ( " policy " ) . toString ( )
signature = it . get ( " signature " ) . toString ( )
keyPairID = it . get ( " key_pair_id " ) . toString ( )
}
println ( " policy: $policy " )
println ( " signature: $signature " )
println ( " keyPairID: $keyPairID " )
}
2021-12-04 19:55:26 +01:00
2022-01-02 22:39:31 +01:00
/ * *
* Retrieve the account id and set the corresponding global var .
* The account id is needed for other calls .
*
* This must be execute on every start for teapod to work properly !
* /
suspend fun account ( ) {
val indexEndpoint = " /accounts/v1/me "
val result = request ( indexEndpoint )
result . component1 ( ) ?. obj ( ) ?. let {
accountID = it . get ( " account_id " ) . toString ( )
}
}
/ * *
* Main media functions : browse , search , series , season , episodes , playback
* /
2021-12-04 19:55:26 +01:00
2021-12-20 22:14:58 +01:00
// TODO locale de-DE, categories
2021-12-05 01:34:06 +01:00
/ * *
* Browse the media available on crunchyroll .
*
* @param sortBy
* @param n Number of items to return , defaults to 10
*
* @return A * * [ BrowseResult ] * * object is returned .
* /
2021-12-27 21:14:35 +01:00
suspend fun browse ( sortBy : SortBy = SortBy . ALPHABETICAL , start : Int = 0 , n : Int = 10 ) : BrowseResult {
2021-12-04 19:55:26 +01:00
val browseEndpoint = " /content/v1/browse "
2021-12-27 21:14:35 +01:00
val parameters = listOf ( " sort_by " to sortBy . str , " start " to start , " n " to n )
2021-12-04 19:55:26 +01:00
2021-12-05 00:42:56 +01:00
val result = request ( browseEndpoint , parameters )
2021-12-20 22:14:58 +01:00
val browseResult = result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneBrowseResult
2021-12-04 19:55:26 +01:00
2021-12-20 22:14:58 +01:00
// add results to cache TODO improve
browsingCache . clear ( )
browsingCache . addAll ( browseResult . items )
2021-12-04 19:55:26 +01:00
2021-12-20 22:14:58 +01:00
return browseResult
2021-12-04 19:55:26 +01:00
}
2021-12-27 22:50:29 +01:00
/ * *
* TODO
* /
suspend fun search ( query : String , n : Int = 10 ) : SearchResult {
2021-12-04 19:55:26 +01:00
val searchEndpoint = " /content/v1/search "
2021-12-27 22:50:29 +01:00
val parameters = listOf ( " q " to query , " n " to n , " locale " to locale , " type " to " series " )
2021-12-04 19:55:26 +01:00
2021-12-20 22:14:58 +01:00
val result = request ( searchEndpoint , parameters )
2021-12-27 22:50:29 +01:00
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
// to work around this, for now only tv shows are supported
2021-12-04 19:55:26 +01:00
2021-12-27 22:50:29 +01:00
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneSearchResult
2021-12-04 19:55:26 +01:00
}
2021-12-05 01:34:06 +01:00
/ * *
2021-12-20 22:14:58 +01:00
* series id == crunchyroll id ?
2021-12-05 01:34:06 +01:00
* /
2021-12-20 22:14:58 +01:00
suspend fun series ( seriesId : String ) : Series {
val seriesEndpoint = " /cms/v2/ $country /M3/crunchyroll/series/ $seriesId "
val parameters = listOf (
" locale " to locale ,
" Signature " to signature ,
" Policy " to policy ,
" Key-Pair-Id " to keyPairID
)
2021-12-05 01:34:06 +01:00
2021-12-20 22:14:58 +01:00
val result = request ( seriesEndpoint , parameters )
2021-12-05 01:34:06 +01:00
2021-12-20 22:14:58 +01:00
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneSeries
}
suspend fun seasons ( seriesId : String ) : Seasons {
val episodesEndpoint = " /cms/v2/ $country /M3/crunchyroll/seasons "
val parameters = listOf (
" series_id " to seriesId ,
" locale " to locale ,
" Signature " to signature ,
" Policy " to policy ,
" Key-Pair-Id " to keyPairID
)
val result = request ( episodesEndpoint , parameters )
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneSeasons
}
suspend fun episodes ( seasonId : String ) : Episodes {
val episodesEndpoint = " /cms/v2/ $country /M3/crunchyroll/episodes "
val parameters = listOf (
" season_id " to seasonId ,
" locale " to locale ,
" Signature " to signature ,
" Policy " to policy ,
" Key-Pair-Id " to keyPairID
)
val result = request ( episodesEndpoint , parameters )
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneEpisodes
}
suspend fun playback ( url : String ) : Playback {
val result = request ( " " , url = url )
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NonePlayback
2021-12-05 01:34:06 +01:00
}
2022-01-02 22:39:31 +01:00
/ * *
* Additional media functions : watchlist , playhead
* /
/ * *
* Check if a media is in the user ' s watchlist .
*
* @param seriesId The crunchyroll series id of the media to check
* @return Boolean : ture if it was found , else false
* /
suspend fun isWatchlist ( seriesId : String ) : Boolean {
val watchlistEndpoint = " /content/v1/watchlist/ $accountID / $seriesId "
val parameters = listOf ( " locale " to locale )
val result = request ( watchlistEndpoint , parameters )
// if needed implement parsing
return result . component1 ( ) ?. obj ( ) ?. has ( seriesId ) ?: false
}
/ * *
* Add a media to the user ' s watchlist .
*
* @param seriesId The crunchyroll series id of the media to check
* /
suspend fun postWatchlist ( seriesId : String ) {
val watchlistEndpoint = " /content/v1/watchlist/ $accountID "
val parameters = listOf ( " locale " to locale )
val json = buildJsonObject {
put ( " content_id " , seriesId )
}
requestPost ( watchlistEndpoint , parameters , json . toString ( ) )
}
/ * *
* Remove a media from the user ' s watchlist .
*
* @param seriesId The crunchyroll series id of the media to check
* /
suspend fun deleteWatchlist ( seriesId : String ) {
val watchlistEndpoint = " /content/v1/watchlist/ $accountID / $seriesId "
val parameters = listOf ( " locale " to locale )
requestDelete ( watchlistEndpoint , parameters )
}
/ * *
* TODO
* /
suspend fun playhead ( ) {
// implement
}
2022-01-02 17:59:23 +01:00
}