/** * Teapod * * Copyright 2020-2022 * * 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, ExperimentalCoroutinesApi::class) private val tokenRefreshContext = newSingleThreadContext("TokenRefreshContext") private var accountID = "" private var externalID = "" private val browsingCache = hashMapOf() /** * 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 request( url: String, httpMethod: HttpMethod, params: List> = 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() } } /** * Send a HTTP GET request with [params] to the [endpoint] at [url], if url is empty use baseUrl */ private suspend inline fun requestGet( endpoint: String, params: List> = 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> = 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> = 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> = 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: account * Needed for other functions to work properly! */ /** * 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. * * @param start start of the item list, used for pagination, default = 0 * @param n number of items to return, default = 10 * @param sortBy the sort order, see **[SortBy]** * @param ratings add user rating to the objects, default = false * @param seasonTag filter by season tag, if present * @param categories filter by category, if present * @return A **[BrowseResult]** object is returned. */ suspend fun browse( start: Int = 0, n: Int = 10, sortBy: SortBy = SortBy.ALPHABETICAL, ratings: Boolean = false, seasonTag: String = "", categories: List = emptyList() ): BrowseResult { val browseEndpoint = "/content/v2/discover/browse" val parameters = mutableListOf( "start" to start, "n" to n, "sort_by" to sortBy.str, "ratings" to ratings, "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), ) // 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 10 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 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. * * @param query The query term as String * @param n The maximum number of results to return, default = 10 * @param ratings add user rating to the objects, default = false * @return A **[SearchResult]** object */ suspend fun search(query: String, n: Int = 10, ratings: Boolean = false): SearchResult { val searchEndpoint = "/content/v2/discover/search" val parameters = listOf( "q" to query, "n" to n, "type" to "series", "ratings" to ratings, "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), "locale" to Preferences.preferredSubtitleLocale.toLanguageTag() ) // 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 add user rating to the objects * @return A **[Collection]** of Panels */ suspend fun objects(objects: List, ratings: Boolean = false): CollectionV2 { 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) NoneCollectionV2 } } /** * 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 CollectionV2) .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]** containing playback info. */ suspend fun playheads(episodeIDs: List): 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 * @param ratings add user rating to the objects * @return A **[SimilarToResult]** object */ suspend fun similarTo(seriesId: String, n: Int = 10, ratings: Boolean = false): SimilarToResult { val similarToEndpoint = "/content/v2/discover/$accountID/similar_to/$seriesId" val parameters = listOf( "n" to n, "ratings" to ratings, "preferred_audio_language" to Preferences.preferredSubtitleLocale.toLanguageTag(), "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), ) 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): CollectionV2 { 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, default = 20 * @return A **[HistoryList]** containing up to n **[UpNextAccountItem]**. */ suspend fun upNextAccount(n: Int = 10): 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 } } /** * Returns a collection of recommendations for the currently logged in account. * * @param start start of the item list, used for pagination, default = 0 * @param n number of items to return, default = 10 * @param ratings add user rating to the objects, default = false * @return A **[RecommendationsList]** containing up to n **[Item]**. */ suspend fun recommendations(start: Int = 0, n: Int = 10, ratings: Boolean = false): RecommendationsList { val recommendationsEndpoint = "/content/v2/discover/$accountID/recommendations" val parameters = listOf( "start" to start, "n" to n, "ratings" to ratings, "locale" to Preferences.preferredSubtitleLocale.toLanguageTag(), ) 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 setPreferredSubtitleLanguage(languageTag: String) { val profileEndpoint = "/accounts/v1/me/profile" val json = buildJsonObject { put("preferred_content_subtitle_language", languageTag) } requestPatch(profileEndpoint, bodyObject = json) } /** * Patch the preferred content audio language. * * @param languageTag the preferred language as language tag */ suspend fun setPreferredAudioLanguage(languageTag: String) { val profileEndpoint = "/accounts/v1/me/profile" val json = buildJsonObject { put("preferred_content_audio_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 } } }