/** * 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.features.* import io.ktor.client.features.json.* import io.ktor.client.features.json.serializer.* import io.ktor.client.request.* import io.ktor.client.request.forms.* import io.ktor.client.statement.* import io.ktor.http.* import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerializationException 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 private val json = Json { ignoreUnknownKeys = true } object Crunchyroll { private val TAG = javaClass.name private val client = HttpClient { install(JsonFeature) { serializer = KotlinxSerializer(json) } } private const val baseUrl = "https://beta-api.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 private val tokeRefreshMutex = Mutex() private var accountID = "" private var policy = "" private var signature = "" private var keyPairID = "" 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(basicApiTokenUrl) as HttpResponse).readText() 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.receive() 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 { // TODO find a better way to make token refresh thread safe, currently it's blocking tokeRefreshMutex.withLock { if (System.currentTimeMillis() > tokenValidUntil) refreshToken() } return@coroutineScope (Dispatchers.IO) { val response: T = 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) { body = bodyObject contentType(ContentType.Application.Json) } } response } } private suspend inline fun requestGet( endpoint: String, params: List> = listOf(), url: String = "" ): T { val path = url.ifEmpty { "$baseUrl$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: 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: SerializationException) { Log.e(TAG, "SerializationException in account(). This is bad!", ex) NoneAccount } accountID = account.accountId } /** * General element/media functions: browse, search, objects, season_list */ /** * Browse the media available on crunchyroll. * * @param sortBy * @param n Number of items to return, defaults to 10 * * @return A **[BrowseResult]** object is returned. */ suspend fun browse( categories: List = 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.preferredLocale.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: SerializationException) { 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 if (browsingCache.size > 100) { 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 * @return A **[SearchResult]** object */ suspend fun search(query: String, n: Int = 10): SearchResult { val searchEndpoint = "/content/v1/search" val parameters = listOf( "locale" to Preferences.preferredLocale.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: SerializationException) { Log.e(TAG, "SerializationException 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 * @return A **[Collection]** of Panels */ suspend fun objects(objects: List): Collection { val episodesEndpoint = "/cms/v2/DE/M3/crunchyroll/objects/${objects.joinToString(",")}" val parameters = listOf( "locale" to Preferences.preferredLocale.toLanguageTag(), "Signature" to signature, "Policy" to policy, "Key-Pair-Id" to keyPairID ) return try { requestGet(episodesEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in objects().", ex) NoneCollection } } /** * List all available seasons as **[SeasonListItem]**. */ @Suppress("unused") suspend fun seasonList(): DiscSeasonList { val seasonListEndpoint = "/content/v1/season_list" val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) return try { requestGet(seasonListEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in seasonList().", ex) NoneDiscSeasonList } } /** * Main media functions: series, season, episodes, playback */ /** * series id == crunchyroll id? */ suspend fun series(seriesId: String): Series { val seriesEndpoint = "/cms/v2/${token.country}/M3/crunchyroll/series/$seriesId" val parameters = listOf( "locale" to Preferences.preferredLocale.toLanguageTag(), "Signature" to signature, "Policy" to policy, "Key-Pair-Id" to keyPairID ) return try { requestGet(seriesEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in series().", ex) NoneSeries } } /** * Get the next episode for a series. * * @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): UpNextSeriesItem { val upNextSeriesEndpoint = "/content/v1/up_next_series" val parameters = listOf( "series_id" to seriesId, "locale" to Preferences.preferredLocale.toLanguageTag() ) return try { requestGet(upNextSeriesEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in upNextSeries().", ex) NoneUpNextSeriesItem } } /** * 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 = "/cms/v2/${token.country}/M3/crunchyroll/seasons" val parameters = listOf( "series_id" to seriesId, "locale" to Preferences.preferredLocale.toLanguageTag(), "Signature" to signature, "Policy" to policy, "Key-Pair-Id" to keyPairID ) return try { requestGet(seasonsEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in seasons().", 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 = "/cms/v2/${token.country}/M3/crunchyroll/episodes" val parameters = listOf( "season_id" to seasonId, "locale" to Preferences.preferredLocale.toLanguageTag(), "Signature" to signature, "Policy" to policy, "Key-Pair-Id" to keyPairID ) return try { requestGet(episodesEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in episodes().", ex) NoneEpisodes } } /** * Get all available subtitles and streams of a episode. * * @param url The playback url of a episode * @return A **[Playback]** object */ suspend fun playback(url: String): Playback { return try { requestGet("", url = url) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in playback(), with url = $url.", ex) NonePlayback } } /** * 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/v1/watchlist/$accountID/$seriesId" val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) return try { (requestGet(watchlistSeriesEndpoint, parameters) as JsonObject) .containsKey(seriesId) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException 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/v1/watchlist/$accountID" val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) val json = buildJsonObject { put("content_id", seriesId) } requestPost(watchlistPostEndpoint, parameters, json) } /** * 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/v1/watchlist/$accountID/$seriesId" val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) requestDelete(watchlistDeleteEndpoint, parameters) } /** * 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): PlayheadsMap { val playheadsEndpoint = "/content/v1/playheads/$accountID/${episodeIDs.joinToString(",")}" val parameters = listOf("locale" to Preferences.preferredLocale.toLanguageTag()) return try { requestGet(playheadsEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in upNextSeries().", ex) emptyMap() } } /** * 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.preferredLocale.toLanguageTag()) val json = buildJsonObject { put("content_id", episodeId) put("playhead", playhead) } requestPost(playheadsEndpoint, parameters, json) } /** * 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.preferredLocale.toLanguageTag(), "n" to n ) return try { requestGet(similarToEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException 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 **[Watchlist]** containing up to n **[Item]**. */ suspend fun watchlist(n: Int = 20): Watchlist { val watchlistEndpoint = "/content/v1/$accountID/watchlist" val parameters = listOf( "locale" to Preferences.preferredLocale.toLanguageTag(), "n" to n ) val list: ContinueWatchingList = try { requestGet(watchlistEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in watchlist().", ex) NoneContinueWatchingList } val objects = list.items.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 **[ContinueWatchingList]** containing up to n **[ContinueWatchingItem]**. */ suspend fun upNextAccount(n: Int = 20): ContinueWatchingList { val watchlistEndpoint = "/content/v1/$accountID/up_next_account" val parameters = listOf( "locale" to Preferences.preferredLocale.toLanguageTag(), "n" to n ) return try { requestGet(watchlistEndpoint, parameters) }catch (ex: SerializationException) { Log.e(TAG, "SerializationException in upNextAccount().", ex) NoneContinueWatchingList } } /** * 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: SerializationException) { Log.e(TAG, "SerializationException 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) } }