770 lines
26 KiB
Kotlin
770 lines
26 KiB
Kotlin
/**
|
|
* Teapod
|
|
*
|
|
* Copyright 2020-2022 <seil0@mosad.xyz>
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program; if not, write to the Free Software
|
|
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
|
|
* MA 02110-1301, USA.
|
|
*
|
|
*/
|
|
|
|
package org.mosad.teapod.parser.crunchyroll
|
|
|
|
import android.util.Log
|
|
import io.ktor.client.*
|
|
import io.ktor.client.call.*
|
|
import io.ktor.client.plugins.*
|
|
import io.ktor.client.plugins.contentnegotiation.*
|
|
import io.ktor.client.request.*
|
|
import io.ktor.client.request.forms.*
|
|
import io.ktor.client.statement.*
|
|
import io.ktor.http.*
|
|
import io.ktor.serialization.*
|
|
import io.ktor.serialization.kotlinx.json.*
|
|
import kotlinx.coroutines.*
|
|
import kotlinx.serialization.json.Json
|
|
import kotlinx.serialization.json.JsonObject
|
|
import kotlinx.serialization.json.buildJsonObject
|
|
import kotlinx.serialization.json.put
|
|
import org.mosad.teapod.preferences.EncryptedPreferences
|
|
import org.mosad.teapod.preferences.Preferences
|
|
|
|
object Crunchyroll {
|
|
private val TAG = javaClass.name
|
|
|
|
private val client = HttpClient {
|
|
install(ContentNegotiation) {
|
|
json(Json {
|
|
ignoreUnknownKeys = true
|
|
})
|
|
}
|
|
}
|
|
private const val baseUrl = "https://beta-api.crunchyroll.com"
|
|
private const val staticUrl = "https://static.crunchyroll.com"
|
|
private const val basicApiTokenUrl = "https://gitlab.com/-/snippets/2274956/raw/main/snippetfile1.txt"
|
|
private var basicApiToken: String = ""
|
|
|
|
private lateinit var token: Token
|
|
private var tokenValidUntil: Long = 0
|
|
@OptIn(DelicateCoroutinesApi::class)
|
|
private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext")
|
|
|
|
private var accountID = ""
|
|
private var externalID = ""
|
|
|
|
private var policy = ""
|
|
private var signature = ""
|
|
private var keyPairID = ""
|
|
|
|
private val browsingCache = hashMapOf<String, BrowseResult>()
|
|
|
|
/**
|
|
* Load the pai token, see:
|
|
* https://git.mosad.xyz/mosad/NonePublicIssues/issues/1
|
|
*
|
|
* TODO handle empty file
|
|
*/
|
|
fun initBasicApiToken() = runBlocking {
|
|
withContext(Dispatchers.IO) {
|
|
basicApiToken = client.get { url(basicApiTokenUrl) }.bodyAsText()
|
|
Log.i(TAG, "basic auth token: $basicApiToken")
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
fun login(username: String, password: String): Boolean = runBlocking {
|
|
val tokenEndpoint = "/auth/v1/token"
|
|
val formData = Parameters.build {
|
|
append("username", username)
|
|
append("password", password)
|
|
append("grant_type", "password")
|
|
append("scope", "offline_access")
|
|
}
|
|
|
|
var success = false// is false
|
|
withContext(Dispatchers.IO) {
|
|
Log.i(TAG, "getting token ...")
|
|
|
|
val status = try {
|
|
val response: HttpResponse = client.submitForm("$baseUrl$tokenEndpoint", formParameters = formData) {
|
|
header("Authorization", "Basic $basicApiToken")
|
|
}
|
|
token = response.body()
|
|
tokenValidUntil = System.currentTimeMillis() + (token.expiresIn * 1000)
|
|
response.status
|
|
} catch (ex: ClientRequestException) {
|
|
val status = ex.response.status
|
|
if (status == HttpStatusCode.Unauthorized) {
|
|
Log.e(TAG, "Could not complete login: " +
|
|
"${status.value} ${status.description}. " +
|
|
"Probably wrong username or password")
|
|
}
|
|
|
|
status
|
|
}
|
|
Log.i(TAG, "Login complete with code $status")
|
|
success = (status == HttpStatusCode.OK)
|
|
}
|
|
|
|
return@runBlocking success
|
|
}
|
|
|
|
private fun refreshToken() {
|
|
login(EncryptedPreferences.login, EncryptedPreferences.password)
|
|
}
|
|
|
|
/**
|
|
* Requests: get, post, delete
|
|
*/
|
|
|
|
private suspend inline fun <reified T> request(
|
|
url: String,
|
|
httpMethod: HttpMethod,
|
|
params: List<Pair<String, Any?>> = listOf(),
|
|
bodyObject: Any = Any()
|
|
): T = coroutineScope {
|
|
withContext(tokenRefreshContext) {
|
|
if (System.currentTimeMillis() > tokenValidUntil) refreshToken()
|
|
}
|
|
|
|
return@coroutineScope (Dispatchers.IO) {
|
|
val response = client.request(url) {
|
|
method = httpMethod
|
|
header("Authorization", "${token.tokenType} ${token.accessToken}")
|
|
params.forEach {
|
|
parameter(it.first, it.second)
|
|
}
|
|
|
|
// for json set body and content type
|
|
if (bodyObject is JsonObject) {
|
|
setBody(bodyObject)
|
|
contentType(ContentType.Application.Json)
|
|
}
|
|
}
|
|
|
|
response.body<T>()
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a HTTP GET request with [params] to the [endpoint] at [url], if url is empty use baseUrl
|
|
*/
|
|
private suspend inline fun <reified T> requestGet(
|
|
endpoint: String,
|
|
params: List<Pair<String, Any?>> = listOf(),
|
|
url: String = ""
|
|
): T {
|
|
val path = url.ifEmpty { baseUrl }.plus(endpoint)
|
|
|
|
return request(path, HttpMethod.Get, params)
|
|
}
|
|
|
|
private suspend fun requestPost(
|
|
endpoint: String,
|
|
params: List<Pair<String, Any?>> = listOf(),
|
|
bodyObject: JsonObject
|
|
) {
|
|
val path = "$baseUrl$endpoint"
|
|
|
|
val response: HttpResponse = request(path, HttpMethod.Post, params, bodyObject)
|
|
Log.i(TAG, "Response: $response")
|
|
}
|
|
|
|
private suspend fun requestPatch(
|
|
endpoint: String,
|
|
params: List<Pair<String, Any?>> = listOf(),
|
|
bodyObject: JsonObject
|
|
) {
|
|
val path = "$baseUrl$endpoint"
|
|
|
|
val response: HttpResponse = request(path, HttpMethod.Patch, params, bodyObject)
|
|
Log.i(TAG, "Response: $response")
|
|
}
|
|
|
|
private suspend fun requestDelete(
|
|
endpoint: String,
|
|
params: List<Pair<String, Any?>> = listOf(),
|
|
url: String = ""
|
|
) = coroutineScope {
|
|
val path = url.ifEmpty { "$baseUrl$endpoint" }
|
|
|
|
val response: HttpResponse = request(path, HttpMethod.Delete, params)
|
|
Log.i(TAG, "Response: $response")
|
|
}
|
|
|
|
/**
|
|
* Basic functions: index, account
|
|
* Needed for other functions to work properly!
|
|
*/
|
|
|
|
/**
|
|
* Retrieve the identifiers necessary for streaming. If the identifiers are
|
|
* retrieved, set the corresponding global var. The identifiers are valid for 24h.
|
|
*/
|
|
suspend fun index() {
|
|
val indexEndpoint = "/index/v2"
|
|
|
|
val index: Index = requestGet(indexEndpoint)
|
|
policy = index.cms.policy
|
|
signature = index.cms.signature
|
|
keyPairID = index.cms.keyPairId
|
|
|
|
Log.i(TAG, "Policy : $policy")
|
|
Log.i(TAG, "Signature : $signature")
|
|
Log.i(TAG, "Key Pair ID : $keyPairID")
|
|
}
|
|
|
|
/**
|
|
* 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 account: Account = try {
|
|
requestGet(indexEndpoint)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "SerializationException in account(). This is bad!", ex)
|
|
NoneAccount
|
|
}
|
|
|
|
accountID = account.accountId
|
|
externalID = account.externalId
|
|
}
|
|
|
|
/**
|
|
* General element/media functions: browse, search, objects, season_list
|
|
*/
|
|
|
|
/**
|
|
* Browse the media available on crunchyroll.
|
|
*
|
|
* TODO migrate to v2
|
|
*
|
|
* @param sortBy
|
|
* @param n Number of items to return, defaults to 10
|
|
*
|
|
* @return A **[BrowseResult]** object is returned.
|
|
*/
|
|
suspend fun browse(
|
|
categories: List<Categories> = emptyList(),
|
|
sortBy: SortBy = SortBy.ALPHABETICAL,
|
|
seasonTag: String = "",
|
|
start: Int = 0,
|
|
n: Int = 10
|
|
): BrowseResult {
|
|
val browseEndpoint = "/content/v1/browse"
|
|
val parameters = mutableListOf(
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"sort_by" to sortBy.str,
|
|
"start" to start,
|
|
"n" to n
|
|
)
|
|
|
|
// if a season tag is present add it to the parameters
|
|
if (seasonTag.isNotEmpty()) {
|
|
parameters.add("season_tag" to seasonTag)
|
|
}
|
|
|
|
// if a season tag is present add it to the parameters
|
|
if (categories.isNotEmpty()) {
|
|
parameters.add("categories" to categories.joinToString(",") { it.str })
|
|
}
|
|
|
|
// fetch result if not already cached
|
|
if (browsingCache.contains(parameters.toString())) {
|
|
Log.d(TAG, "browse result cached: $parameters")
|
|
} else {
|
|
Log.d(TAG, "browse result not cached, fetching: $parameters")
|
|
val browseResult: BrowseResult = try {
|
|
requestGet(browseEndpoint, parameters)
|
|
}catch (ex: Exception) {
|
|
Log.e(TAG, "SerializationException in browse().", ex)
|
|
NoneBrowseResult
|
|
}
|
|
|
|
// if the cache has more than 100 entries clear it, so it doesn't become a memory problem
|
|
// Note: this value is totally guessed and should be replaced by a properly researched value
|
|
// TODO 100 is way to high as it's not the number of items but BrowseResults
|
|
if (browsingCache.size > 10) {
|
|
browsingCache.clear()
|
|
}
|
|
|
|
// add results to cache
|
|
browsingCache[parameters.toString()] = browseResult
|
|
}
|
|
|
|
return browsingCache[parameters.toString()] ?: NoneBrowseResult
|
|
}
|
|
|
|
/**
|
|
* Search fo a query term.
|
|
* Note: currently this function only supports series/tv shows.
|
|
*
|
|
* TODO migrate to v2
|
|
*
|
|
* @param query The query term as String
|
|
* @param n The maximum number of results to return, default = 10
|
|
* @return A **[SearchResult]** object
|
|
*/
|
|
suspend fun search(query: String, n: Int = 10): SearchResult {
|
|
val searchEndpoint = "/content/v1/search"
|
|
val parameters = listOf(
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"q" to query,
|
|
"n" to n,
|
|
"type" to "series"
|
|
)
|
|
|
|
// TODO episodes have thumbnails as image, and not poster_tall/poster_tall,
|
|
// to work around this, for now only tv shows are supported
|
|
|
|
return try {
|
|
requestGet(searchEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in search(), with query = \"$query\".", ex)
|
|
NoneSearchResult
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get a collection of series objects.
|
|
* Note: episode objects are currently not supported
|
|
*
|
|
* @param objects The object IDs as list of Strings
|
|
* @param ratings the user rating of the object
|
|
* @return A **[Collection]** of Panels
|
|
*/
|
|
suspend fun objects(objects: List<String>, ratings: Boolean = false): Collection2<Item> {
|
|
val episodesEndpoint = "/content/v2/cms/objects/${objects.joinToString(",")}"
|
|
val parameters = listOf(
|
|
"ratings" to ratings,
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(episodesEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in objects().", ex)
|
|
NoneCollection2
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main media functions: series, season, episodes, playback
|
|
*/
|
|
|
|
/**
|
|
* series id == crunchyroll id?
|
|
*/
|
|
suspend fun series(seriesId: String): Series {
|
|
val seriesEndpoint = "/content/v2/cms/series/$seriesId"
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(seriesEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in series() for id $seriesId.", ex)
|
|
NoneSeries
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the next episode for a series.
|
|
*
|
|
* FIXME up_next returns no content if the is no next episode
|
|
*
|
|
* @param seriesId The series id for which to call up next
|
|
* @return A **[UpNextSeriesItem]** with a Panel representing the up next episode
|
|
*/
|
|
suspend fun upNextSeries(seriesId: String): UpNextSeriesList {
|
|
val upNextSeriesEndpoint = "/content/v2/discover/up_next/$seriesId"
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(upNextSeriesEndpoint, parameters)
|
|
} catch (ex: NoTransformationFoundException) {
|
|
// should be 204 No Content
|
|
NoneUpNextSeriesList
|
|
} catch (ex: JsonConvertException) {
|
|
Log.e(TAG, "JsonConvertException in upNextSeries() with seriesId=$seriesId", ex)
|
|
NoneUpNextSeriesList
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in upNextSeries() for seriesId $seriesId.", ex)
|
|
NoneUpNextSeriesList
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available seasons for a series.
|
|
*
|
|
* @param seriesId The series id for which to get the seasons
|
|
* @return A **[Seasons]** object with a list of **[Season]**
|
|
*/
|
|
suspend fun seasons(seriesId: String): Seasons {
|
|
val seasonsEndpoint = "/content/v2/cms/series/$seriesId/seasons"
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(seasonsEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in seasons() for seriesId $seriesId.", ex)
|
|
NoneSeasons
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available episodes for a season.
|
|
*
|
|
* @param seasonId The season id for which to get the episodes
|
|
* @return A **[Episodes]** object with a list of **[Episode]**
|
|
*/
|
|
suspend fun episodes(seasonId: String): Episodes {
|
|
val episodesEndpoint = "/content/v2/cms/seasons/$seasonId/episodes"
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(episodesEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in episodes() for seasonId $seasonId.", ex)
|
|
NoneEpisodes
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all available subtitles and streams of a episode.
|
|
*
|
|
* @param url The streams url of a episode
|
|
* @return A **[Streams]** object
|
|
*/
|
|
suspend fun streams(url: String): Streams {
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(url, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in streams() with url $url.", ex)
|
|
NoneStreams
|
|
}
|
|
}
|
|
|
|
suspend fun streamsFromMediaGUID(mediaGUID: String): Streams {
|
|
val streamsEndpoint = "/content/v2/cms/videos/$mediaGUID/streams"
|
|
return streams(streamsEndpoint)
|
|
}
|
|
|
|
/**
|
|
* Additional media functions: watchlist (series), playhead, similar to
|
|
*/
|
|
|
|
/**
|
|
* 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 watchlistSeriesEndpoint = "/content/v2/$accountID/watchlist"
|
|
val parameters = listOf(
|
|
"content_ids" to seriesId,
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
(requestGet(watchlistSeriesEndpoint, parameters) as Collection2<IsWatchlistItem>)
|
|
.total == 1
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in isWatchlist() with seriesId $seriesId", ex)
|
|
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 watchlistPostEndpoint = "/content/v2/$accountID/watchlist"
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
val json = buildJsonObject {
|
|
put("content_id", seriesId)
|
|
}
|
|
|
|
try {
|
|
requestPost(watchlistPostEndpoint, parameters, json)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in postWatchlist() with seriesId $seriesId", ex)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 watchlistDeleteEndpoint = "/content/v2/$accountID/watchlist/$seriesId"
|
|
val parameters = listOf(
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
try {
|
|
requestDelete(watchlistDeleteEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in deleteWatchlist() with seriesId $seriesId", ex)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
suspend fun playheads(episodeIDs: List<String>): Playheads {
|
|
val playheadsEndpoint = "/content/v2/$accountID/playheads"
|
|
val parameters = listOf(
|
|
"content_ids" to episodeIDs.joinToString(","),
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag(),
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag()
|
|
)
|
|
|
|
return try {
|
|
requestGet(playheadsEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in playheads().", ex.cause)
|
|
NonePlayheads
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Post the playhead to crunchy (playhead position,watched state)
|
|
*
|
|
* @param episodeId A episode ID as strings.
|
|
* @param playhead The episodes playhead in seconds.
|
|
*/
|
|
suspend fun postPlayheads(episodeId: String, playhead: Int) {
|
|
val playheadsEndpoint = "/content/v1/playheads/$accountID"
|
|
val parameters = listOf("locale" to Preferences.preferredSubtitleLocale.toLanguageTag())
|
|
|
|
val json = buildJsonObject {
|
|
put("content_id", episodeId)
|
|
put("playhead", playhead)
|
|
}
|
|
|
|
try {
|
|
requestPost(playheadsEndpoint, parameters, json)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in postPlayheads()", ex.cause)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get the intro meta data including start, end and duration of the intro.
|
|
*
|
|
* @param episodeId A episode ID as strings.
|
|
*/
|
|
suspend fun datalabIntro(episodeId: String): DatalabIntro {
|
|
val datalabIntroEndpoint = "/datalab-intro-v2/$episodeId.json"
|
|
|
|
/*
|
|
* wtf crunchyroll, why do you return an xml error message when some data is missing,
|
|
* this is a json endpoint. For fucks sake, return at least a valid json message.
|
|
*/
|
|
return try {
|
|
val response: HttpResponse = requestGet(datalabIntroEndpoint, url = staticUrl)
|
|
Json.decodeFromString(response.bodyAsText())
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in datalabIntro(). EpisodeId=$episodeId", ex)
|
|
NoneDatalabIntro
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get similar media for a show/movie.
|
|
*
|
|
* @param seriesId The crunchyroll series id of the media
|
|
* @param n The maximum number of results to return, default = 10
|
|
* @return A **[SimilarToResult]** object
|
|
*/
|
|
suspend fun similarTo(seriesId: String, n: Int = 10): SimilarToResult {
|
|
val similarToEndpoint = "/content/v1/$accountID/similar_to"
|
|
val parameters = listOf(
|
|
"guid" to seriesId,
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"n" to n
|
|
)
|
|
|
|
return try {
|
|
requestGet(similarToEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in similarTo().", ex)
|
|
NoneSimilarToResult
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Listing functions: watchlist (list), up_next_account
|
|
*/
|
|
|
|
/**
|
|
* List items present in the watchlist.
|
|
*
|
|
* @param n Number of items to return, defaults to 20.
|
|
* @return A **[Collection]** containing up to n **[Item]**.
|
|
*/
|
|
suspend fun watchlist(n: Int = 20): Collection2<Item> {
|
|
val watchlistEndpoint = "/content/v2/discover/$accountID/watchlist"
|
|
val parameters = listOf(
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"n" to n,
|
|
"preferred_audio_language" to Preferences.preferredAudioLocale.toLanguageTag()
|
|
)
|
|
|
|
val list: Watchlist = try {
|
|
requestGet(watchlistEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in watchlist().", ex)
|
|
NoneWatchlist
|
|
}
|
|
|
|
val objects = list.data.map{ it.panel.episodeMetadata.seriesId }
|
|
return objects(objects)
|
|
}
|
|
|
|
/**
|
|
* List the next up episodes for the logged in account.
|
|
*
|
|
* @param n Number of items to return, defaults to 20.
|
|
* @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**.
|
|
*/
|
|
suspend fun upNextAccount(n: Int = 20): HistoryList {
|
|
val watchlistEndpoint = "/content/v2/discover/$accountID/history"
|
|
val parameters = listOf(
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"n" to n
|
|
)
|
|
|
|
return try {
|
|
requestGet(watchlistEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in upNextAccount().", ex)
|
|
NoneHistoryList
|
|
}
|
|
}
|
|
|
|
suspend fun recommendations(n: Int = 20, start: Int = 0): RecommendationsList {
|
|
val recommendationsEndpoint = "/content/v1/$accountID/recommendations"
|
|
val parameters = listOf(
|
|
"locale" to Preferences.preferredSubtitleLocale.toLanguageTag(),
|
|
"n" to n,
|
|
"start" to start,
|
|
"variant_id" to 0
|
|
)
|
|
|
|
return try {
|
|
requestGet(recommendationsEndpoint, parameters)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in recommendations().", ex)
|
|
NoneRecommendationsList
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Account/Profile functions
|
|
*/
|
|
|
|
/**
|
|
* Get profile information for the currently logged in account.
|
|
*
|
|
* @return A **[Profile]** object
|
|
*/
|
|
suspend fun profile(): Profile {
|
|
val profileEndpoint = "/accounts/v1/me/profile"
|
|
|
|
return try {
|
|
requestGet(profileEndpoint)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in profile().", ex)
|
|
NoneProfile
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Post the preferred content subtitle language.
|
|
*
|
|
* @param languageTag the preferred language as language tag
|
|
*/
|
|
suspend fun postPrefSubLanguage(languageTag: String) {
|
|
val profileEndpoint = "/accounts/v1/me/profile"
|
|
val json = buildJsonObject {
|
|
put("preferred_content_subtitle_language", languageTag)
|
|
}
|
|
|
|
requestPatch(profileEndpoint, bodyObject = json)
|
|
}
|
|
|
|
/**
|
|
* Get additional profile (benefits) information for the currently logged in account.
|
|
*
|
|
* * @return A **[Profile]** object
|
|
*/
|
|
suspend fun benefits(): Benefits {
|
|
val profileEndpoint = "/subs/v1/subscriptions/$externalID/benefits"
|
|
|
|
return try {
|
|
requestGet(profileEndpoint)
|
|
} catch (ex: Exception) {
|
|
Log.e(TAG, "Exception in benefits().", ex)
|
|
NoneBenefits
|
|
}
|
|
}
|
|
|
|
}
|