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
2022-01-05 01:28:39 +01:00
import org.mosad.teapod.util.concatenate
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
2022-01-08 19:20:21 +01:00
/ * *
* Login to the crunchyroll API .
*
* @param username The Username / Email of the user to log in
* @param password The Accounts Password
*
* @return Boolean : True if login was successful , else false
* /
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 "
)
2022-01-08 19:20:21 +01:00
var success : Boolean // is false
2021-12-04 19:55:26 +01:00
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} " )
2022-01-08 19:20:21 +01:00
success = ( response . statusCode == 200 )
2021-12-04 19:55:26 +01:00
}
2022-01-08 19:20:21 +01:00
return @runBlocking success
2021-12-04 19:55:26 +01:00
}
2022-01-06 18:39:23 +01:00
/ * *
* Requests : get , post , delete
* /
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 ( )
}
}
/ * *
2022-01-06 18:39:23 +01:00
* General element / media functions : browse , search , objects , season _list
2022-01-02 22:39:31 +01:00
* /
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 .
* /
2022-01-05 01:28:39 +01:00
suspend fun browse (
sortBy : SortBy = SortBy . ALPHABETICAL ,
seasonTag : String = " " ,
start : Int = 0 ,
n : Int = 10
) : BrowseResult {
2021-12-04 19:55:26 +01:00
val browseEndpoint = " /content/v1/browse "
2022-01-05 01:28:39 +01:00
val noneOptParams = listOf ( " sort_by " to sortBy . str , " start " to start , " n " to n )
// if a season tag is present add it to the parameters
2022-01-06 18:39:23 +01:00
val parameters = if ( seasonTag . isNotEmpty ( ) ) {
2022-01-05 01:28:39 +01:00
concatenate ( noneOptParams , listOf ( " season_tag " to seasonTag ) )
} else {
noneOptParams
}
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
}
2022-01-03 14:10:41 +01:00
/ * *
* Get a collection of series objects .
* Note : episode objects are currently not supported
*
* @param objects The object IDs as list of Strings
2022-01-05 01:28:39 +01:00
* @return A * * [ Collection ] * * of Panels
2022-01-03 14:10:41 +01:00
* /
2022-01-06 18:39:23 +01:00
suspend fun objects ( objects : List < String > ) : Collection < Item > {
2022-01-03 14:10:41 +01:00
val episodesEndpoint = " /cms/v2/DE/M3/crunchyroll/objects/ ${objects.joinToString(",")} "
val parameters = listOf (
" 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 ( ) )
} ?: NoneCollection
}
2022-01-06 18:39:23 +01:00
/ * *
* List all available seasons as * * [ SeasonListItem ] * * .
* /
@Suppress ( " unused " )
suspend fun seasonList ( ) : DiscSeasonList {
val seasonListEndpoint = " /content/v1/season_list "
val parameters = listOf ( " locale " to locale )
val result = request ( seasonListEndpoint , parameters )
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneDiscSeasonList
}
/ * *
* Main media functions : series , season , episodes , playback
* /
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
}
2022-01-09 18:41:23 +01:00
/ * *
* TODO
* /
suspend fun upNextSeries ( seriesId : String ) : UpNextSeriesItem {
val upNextSeriesEndpoint = " /content/v1/up_next_series "
val parameters = listOf (
" series_id " to seriesId ,
" locale " to locale
)
val result = request ( upNextSeriesEndpoint , parameters )
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneUpNextSeriesItem
}
2021-12-20 22:14:58 +01:00
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
/ * *
2022-01-03 14:10:41 +01:00
* Additional media functions : watchlist ( series ) , playhead
2022-01-02 22:39:31 +01:00
* /
/ * *
* Check if a media is in the user ' s watchlist .
*
* @param seriesId The crunchyroll series id of the media to check
2022-01-05 01:28:39 +01:00
* @return * * [ Boolean ] * * : ture if it was found , else false
2022-01-02 22:39:31 +01:00
* /
suspend fun isWatchlist ( seriesId : String ) : Boolean {
2022-01-03 14:10:41 +01:00
val watchlistSeriesEndpoint = " /content/v1/watchlist/ $accountID / $seriesId "
2022-01-02 22:39:31 +01:00
val parameters = listOf ( " locale " to locale )
2022-01-03 14:10:41 +01:00
val result = request ( watchlistSeriesEndpoint , parameters )
2022-01-02 22:39:31 +01:00
// 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 ) {
2022-01-03 14:10:41 +01:00
val watchlistPostEndpoint = " /content/v1/watchlist/ $accountID "
2022-01-02 22:39:31 +01:00
val parameters = listOf ( " locale " to locale )
val json = buildJsonObject {
put ( " content_id " , seriesId )
}
2022-01-03 14:10:41 +01:00
requestPost ( watchlistPostEndpoint , parameters , json . toString ( ) )
2022-01-02 22:39:31 +01:00
}
/ * *
* Remove a media from the user ' s watchlist .
*
* @param seriesId The crunchyroll series id of the media to check
* /
suspend fun deleteWatchlist ( seriesId : String ) {
2022-01-03 14:10:41 +01:00
val watchlistDeleteEndpoint = " /content/v1/watchlist/ $accountID / $seriesId "
2022-01-02 22:39:31 +01:00
val parameters = listOf ( " locale " to locale )
2022-01-03 14:10:41 +01:00
requestDelete ( watchlistDeleteEndpoint , parameters )
2022-01-02 22:39:31 +01:00
}
/ * *
2022-01-05 01:28:39 +01:00
* Get playhead information for all episodes in episodeIDs .
* The Information returned contains the playhead position , watched state
* and last modified date .
*
* @param episodeIDs A * * [ List ] * * of episodes IDs as strings .
* @return A * * [ Map ] * * < String , * * [ PlayheadObject ] * * > containing playback info .
2022-01-02 22:39:31 +01:00
* /
2022-01-05 01:28:39 +01:00
suspend fun playheads ( episodeIDs : List < String > ) : PlayheadsMap {
val playheadsEndpoint = " /content/v1/playheads/ $accountID / ${episodeIDs.joinToString(",")} "
val parameters = listOf ( " locale " to locale )
val result = request ( playheadsEndpoint , parameters )
return result . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: emptyMap ( )
2022-01-02 22:39:31 +01:00
}
2022-01-09 18:41:23 +01:00
suspend fun postPlayheads ( episodeId : String , playhead : Int ) {
val playheadsEndpoint = " /content/v1/playheads/ $accountID "
val parameters = listOf ( " locale " to locale )
val json = buildJsonObject {
put ( " content_id " , episodeId )
put ( " playhead " , playhead )
}
requestPost ( playheadsEndpoint , parameters , json . toString ( ) )
}
2022-01-03 14:10:41 +01:00
/ * *
* Listing functions : watchlist ( list ) , up _next _account
* /
2022-01-05 01:28:39 +01:00
/ * *
* List items present in the watchlist .
*
* @param n Number of items to return , defaults to 20.
* @return A * * [ Watchlist ] * * containing up to n * * [ Item ] * * .
* /
2022-01-03 14:10:41 +01:00
suspend fun watchlist ( n : Int = 20 ) : Watchlist {
val watchlistEndpoint = " /content/v1/ $accountID /watchlist "
val parameters = listOf ( " locale " to locale , " n " to n )
val watchlistResult = request ( watchlistEndpoint , parameters )
val list : ContinueWatchingList = watchlistResult . component1 ( ) ?. obj ( ) ?. let {
json . decodeFromString ( it . toString ( ) )
} ?: NoneContinueWatchingList
val objects = list . items . map { it . panel . episodeMetadata . seriesId }
return objects ( objects )
}
2022-01-05 00:28:49 +01:00
/ * *
2022-01-05 01:28:39 +01:00
* List the next up episodes for the logged in account .
*
* @param n Number of items to return , defaults to 20.
* @return A * * [ ContinueWatchingList ] * * containing up to n * * [ ContinueWatchingItem ] * * .
2022-01-05 00:28:49 +01:00
* /
suspend fun upNextAccount ( n : Int = 20 ) : ContinueWatchingList {
val watchlistEndpoint = " /content/v1/ $accountID /up_next_account "
val parameters = listOf ( " locale " to locale , " n " to n )
val resultUpNextAccount = request ( watchlistEndpoint , parameters )
2022-01-05 01:28:39 +01:00
return resultUpNextAccount . component1 ( ) ?. obj ( ) ?. let {
2022-01-05 00:28:49 +01:00
json . decodeFromString ( it . toString ( ) )
} ?: NoneContinueWatchingList
}
2022-01-02 17:59:23 +01:00
}