diff --git a/src/all/kamyroll/build.gradle b/src/all/kamyroll/build.gradle index 0c25e0e82..19aaf9514 100644 --- a/src/all/kamyroll/build.gradle +++ b/src/all/kamyroll/build.gradle @@ -3,10 +3,10 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlinx-serialization' ext { - extName = 'Consumyroll' + extName = 'Yomiroll' pkgNameSuffix = 'all.kamyroll' - extClass = '.Consumyroll' - extVersionCode = 15 + extClass = '.Yomiroll' + extVersionCode = 16 libVersion = '13' } diff --git a/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png index ee414e838..de285daf1 100644 Binary files a/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png and b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png index e70f4fdcf..66d8a368e 100644 Binary files a/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png and b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png index ed2e61644..9a281fbba 100644 Binary files a/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png and b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png index 85f4a48ff..c27403f12 100644 Binary files a/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png and b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png index 8f5ab47cc..438dc5dad 100644 Binary files a/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png and b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/web_hi_res_512.png b/src/all/kamyroll/res/web_hi_res_512.png index 0c3de1e45..63c77b7d1 100644 Binary files a/src/all/kamyroll/res/web_hi_res_512.png and b/src/all/kamyroll/res/web_hi_res_512.png differ diff --git a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/AccessTokenInterceptor.kt b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/AccessTokenInterceptor.kt index ec2233f17..9c3aeb7b2 100644 --- a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/AccessTokenInterceptor.kt +++ b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/AccessTokenInterceptor.kt @@ -2,40 +2,47 @@ package eu.kanade.tachiyomi.animeextension.all.kamyroll import android.content.SharedPreferences import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import okhttp3.Headers import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response +import java.net.Authenticator import java.net.HttpURLConnection +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy class AccessTokenInterceptor( - private val baseUrl: String, + private val crUrl: String, private val json: Json, private val preferences: SharedPreferences ) : Interceptor { - private var accessToken = preferences.getString(TOKEN_PREF_KEY, null) ?: "" override fun intercept(chain: Interceptor.Chain): Response { - if (accessToken.isBlank()) accessToken = refreshAccessToken() - - val parsed = json.decodeFromString(accessToken) - val request = newRequestWithAccessToken(chain.request(), "${parsed.token_type} ${parsed.access_token}") + val accessTokenN = getAccessToken() + val request = newRequestWithAccessToken(chain.request(), accessTokenN) val response = chain.proceed(request) - when (response.code) { HttpURLConnection.HTTP_UNAUTHORIZED -> { synchronized(this) { response.close() - // Access token is refreshed in another thread. - accessToken = refreshAccessToken() - val newParsed = json.decodeFromString(accessToken) + // Access token is refreshed in another thread. Check if it has changed. + val newAccessToken = getAccessToken() + if (accessTokenN != newAccessToken) { + return chain.proceed(newRequestWithAccessToken(request, newAccessToken)) + } + val refreshedToken = refreshAccessToken() // Retry the request return chain.proceed( - newRequestWithAccessToken(chain.request(), "${newParsed.token_type} ${newParsed.access_token}") + newRequestWithAccessToken(chain.request(), refreshedToken) ) } } @@ -43,24 +50,70 @@ class AccessTokenInterceptor( } } - private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { + private fun newRequestWithAccessToken(request: Request, tokenData: AccessToken): Request { return request.newBuilder() - .header("authorization", accessToken) + .header("authorization", "${tokenData.token_type} ${tokenData.access_token}") .build() } - fun refreshAccessToken(): String { - val client = OkHttpClient().newBuilder().build() - val response = client.newCall(GET("$baseUrl/token")).execute() - val parsedJson = json.decodeFromString(response.body!!.string()).toJsonString() - preferences.edit().putString(TOKEN_PREF_KEY, parsedJson).apply() - return parsedJson + fun getAccessToken(): AccessToken { + return preferences.getString(TOKEN_PREF_KEY, null)?.toAccessToken() + ?: refreshAccessToken() + } + + private fun refreshAccessToken(): AccessToken { + val client = OkHttpClient() + .newBuilder().build() + val proxy = client.newBuilder() + .proxy( + Proxy( + Proxy.Type.SOCKS, + InetSocketAddress("cr-unblocker.us.to", 1080) + ) + ) + .build() + + Authenticator.setDefault( + object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication("crunblocker", "crunblocker".toCharArray()) + } + } + ) + + // Thanks Stormzy + val refreshTokenResp = client.newCall(GET("https://raw.githubusercontent.com/Stormunblessed/IPTV-CR-NIC/main/logos/refreshtoken.txt")).execute() + val refreshToken = refreshTokenResp.body!!.string().replace("[\n\r]".toRegex(), "") + val headers = Headers.headersOf( + "Content-Type", "application/x-www-form-urlencoded", + "Authorization", "Basic a3ZvcGlzdXZ6Yy0teG96Y21kMXk6R21JSTExenVPVnRnTjdlSWZrSlpibzVuLTRHTlZ0cU8=" + ) + val postBody = "grant_type=refresh_token&refresh_token=$refreshToken&scope=offline_access".toRequestBody("application/x-www-form-urlencoded".toMediaType()) + val response = proxy.newCall(POST("$crUrl/auth/v1/token", headers, postBody)).execute() + val parsedJson = json.decodeFromString(response.body!!.string()) + + val policy = proxy.newCall(newRequestWithAccessToken(GET("$crUrl/index/v2"), parsedJson)).execute() + val policyJson = json.decodeFromString(policy.body!!.string()) + val allTokens = AccessToken( + parsedJson.access_token, + parsedJson.token_type, + policyJson.cms.policy, + policyJson.cms.signature, + policyJson.cms.key_pair_id, + policyJson.cms.bucket + ) + preferences.edit().putString(TOKEN_PREF_KEY, allTokens.toJsonString()).apply() + return allTokens } private fun AccessToken.toJsonString(): String { return json.encodeToString(this) } + private fun String.toAccessToken(): AccessToken { + return json.decodeFromString(this) + } + companion object { val TOKEN_PREF_KEY = "access_token_data" } diff --git a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/DataModel.kt b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/DataModel.kt index 9176ce8f9..e0daa99d1 100644 --- a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/DataModel.kt +++ b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/DataModel.kt @@ -8,12 +8,25 @@ import kotlinx.serialization.json.JsonObject data class AccessToken( val access_token: String, val token_type: String, - val policy: String, - val signature: String, - val key_pair_id: String, - val bucket: String + val policy: String? = null, + val signature: String? = null, + val key_pair_id: String? = null, + val bucket: String? = null ) +@Serializable +data class Policy( + val cms: Tokens +) { + @Serializable + data class Tokens( + val policy: String, + val signature: String, + val key_pair_id: String, + val bucket: String + ) +} + @Serializable data class LinkData( val id: String, @@ -43,9 +56,9 @@ data class Anime( @SerialName("keywords") val genres: ArrayList? = null, val series_metadata: Metadata? = null, - val content_provider: String? = null, - val audio_locales: ArrayList? = null, - val subtitle_locales: ArrayList? = null + @SerialName("movie_listing_metadata") + val movie_metadata: MovieMeta? = null, + val content_provider: String? = null ) { @Serializable data class Metadata( @@ -53,6 +66,18 @@ data class Anime( val is_simulcast: Boolean, val audio_locales: ArrayList, val subtitle_locales: ArrayList, + val is_dubbed: Boolean, + val is_subbed: Boolean, + @SerialName("tenant_categories") + val genres: ArrayList? = null + ) + + @Serializable + data class MovieMeta( + val is_dubbed: Boolean, + val is_subbed: Boolean, + val maturity_ratings: ArrayList, + val subtitle_locales: ArrayList, @SerialName("tenant_categories") val genres: ArrayList? = null ) @@ -71,6 +96,7 @@ data class SearchAnimeResult( @Serializable data class SearchAnime( val type: String, + val count: Int, val items: ArrayList ) } @@ -83,7 +109,9 @@ data class SeasonResult( @Serializable data class Season( val id: String, - val season_number: Int + val season_number: Int? = null, + @SerialName("premium_available_date") + val date: String? = null ) } @@ -113,6 +141,14 @@ data class EpisodeResult( } } +data class TempEpisode( + var epData: EpisodeData, + var name: String, + var episode_number: Float, + var date_upload: Long, + var scanlator: String? +) + @Serializable data class EpisodeData( val ids: List> @@ -121,7 +157,8 @@ data class EpisodeData( @Serializable data class VideoStreams( val streams: Stream, - val subtitles: JsonObject + val subtitles: JsonObject, + val audio_locale: String ) { @Serializable data class Stream( diff --git a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Consumyroll.kt b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Yomiroll.kt similarity index 61% rename from src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Consumyroll.kt rename to src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Yomiroll.kt index 84ce0a04f..f99fe7d54 100644 --- a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Consumyroll.kt +++ b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Yomiroll.kt @@ -34,11 +34,12 @@ import java.text.SimpleDateFormat import java.util.Locale @ExperimentalSerializationApi -class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { +class Yomiroll : ConfigurableAnimeSource, AnimeHttpSource() { - override val name = "Consumyroll" + // No more renaming, no matter what 3rd party service is used :) + override val name = "Yomiroll" - override val baseUrl = "https://cronchy.consumet.stream" + override val baseUrl = "https://crunchyroll.com" private val crUrl = "https://beta-api.crunchyroll.com" @@ -54,10 +55,10 @@ class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { Injekt.get().getSharedPreferences("source_$id", 0x0000) } - private val TokenInterceptor = AccessTokenInterceptor(baseUrl, json, preferences) + private val tokenInterceptor = AccessTokenInterceptor(crUrl, json, preferences) override val client: OkHttpClient = OkHttpClient().newBuilder() - .addInterceptor(TokenInterceptor).build() + .addInterceptor(tokenInterceptor).build() companion object { private val DateFormatter by lazy { @@ -74,10 +75,16 @@ class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { override fun popularAnimeParse(response: Response): AnimesPage { val parsed = json.decodeFromString(response.body!!.string()) - val animeList = parsed.data.filter { it.type == "series" }.map { ani -> - ani.toSAnime() - } - return AnimesPage(animeList, true) + val animeList = parsed.data.parallelMap { ani -> + runCatching { + ani.toSAnime() + }.getOrNull() + }.filterNotNull() + val queries = response.request.url.encodedQuery ?: "0" + val position = if (queries.contains("start=")) { + queries.substringAfter("start=").substringBefore("&").toInt() + } else { 0 } + return AnimesPage(animeList, position + 36 < parsed.total) } // =============================== Latest =============================== @@ -92,25 +99,52 @@ class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { // =============================== Search =============================== override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { - val cleanQuery = query.replace(" ", "+").lowercase() - return GET("$crUrl/content/v2/discover/search?q=$cleanQuery&n=6&type=&locale=en-US") + val params = YomirollFilters.getSearchParameters(filters) + val start = if (page != 1) "start=${(page - 1) * 36}&" else "" + val url = if (query.isNotBlank()) { + val cleanQuery = query.replace(" ", "+").lowercase() + "$crUrl/content/v2/discover/search?${start}n=36&q=$cleanQuery&type=${params.type}" + } else { + "$crUrl/content/v2/discover/browse?${start}n=36${params.media}${params.language}&sort_by=${params.sort}${params.category}" + } + return GET(url) } override fun searchAnimeParse(response: Response): AnimesPage { - val parsed = json.decodeFromString(response.body!!.string()) - val animeList = parsed.data.filter { it.type == "top_results" }.map { result -> - result.items.filter { it.type == "series" }.map { ani -> - ani.toSAnime() + val bod = response.body!!.string() + val total: Int + val animeList = ( + if (response.request.url.encodedPath.contains("search")) { + val parsed = json.decodeFromString(bod).data.first() + total = parsed.count + parsed.items + } else { + val parsed = json.decodeFromString(bod) + total = parsed.total + parsed.data } - }.flatten() - return AnimesPage(animeList, false) + ).parallelMap { ani -> + runCatching { + ani.toSAnime() + }.getOrNull() + }.filterNotNull() + val queries = response.request.url.encodedQuery ?: "0" + val position = if (queries.contains("start=")) { + queries.substringAfter("start=").substringBefore("&").toInt() + } else { 0 } + return AnimesPage(animeList, position + 36 < total) } + override fun getFilterList(): AnimeFilterList = YomirollFilters.filterList + // =========================== Anime Details ============================ override fun fetchAnimeDetails(anime: SAnime): Observable { val mediaId = json.decodeFromString(anime.url) - val resp = client.newCall(GET("$crUrl/content/v2/cms/series/${mediaId.id}?locale=en-US")).execute() + val resp = client.newCall( + if (mediaId.media_type == "series") GET("$crUrl/content/v2/cms/series/${mediaId.id}?locale=en-US") + else GET("$crUrl/content/v2/cms/movie_listings/${mediaId.id}?locale=en-US") + ).execute() val info = json.decodeFromString(resp.body!!.string()) return Observable.just( anime.apply { @@ -129,36 +163,76 @@ class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { override fun episodeListRequest(anime: SAnime): Request { val mediaId = json.decodeFromString(anime.url) - return GET("$crUrl/content/v2/cms/series/${mediaId.id}/seasons") + return if (mediaId.media_type == "series") { + GET("$crUrl/content/v2/cms/series/${mediaId.id}/seasons") + } else { + GET("$crUrl/content/v2/cms/movie_listings/${mediaId.id}/movies") + } } override fun episodeListParse(response: Response): List { val seasons = json.decodeFromString(response.body!!.string()) - return seasons.data.parallelMap { seasonData -> - runCatching { - val episodeResp = client.newCall(GET("$crUrl/content/v2/cms/seasons/${seasonData.id}/episodes")).execute() - val episodes = json.decodeFromString(episodeResp.body!!.string()) - episodes.data.sortedBy { it.episode_number }.map { ep -> - SEpisode.create().apply { - url = EpisodeData( - ep.versions?.map { Pair(it.mediaId, it.audio_locale) } ?: listOf( - Pair( - ep.streams_link.substringAfter("videos/").substringBefore("/streams"), - ep.audio_locale + val series = response.request.url.encodedPath.contains("series/") + // Why all this? well crunchy sends same season twice with different quality eg. One Piece + // which causes the number of episodes to be higher that what it actually is. + return if (series) { + seasons.data.sortedBy { it.season_number }.groupBy { it.season_number } + .map { (_, sList) -> + sList.parallelMap { seasonData -> + runCatching { + val episodeResp = + client.newCall(GET("$crUrl/content/v2/cms/seasons/${seasonData.id}/episodes")) + .execute() + val episodes = + json.decodeFromString(episodeResp.body!!.string()) + episodes.data.sortedBy { it.episode_number }.parallelMap { ep -> + TempEpisode( + epData = EpisodeData( + ep.versions?.map { Pair(it.mediaId, it.audio_locale) } + ?: listOf( + Pair( + ep.streams_link.substringAfter("videos/") + .substringBefore("/streams"), + ep.audio_locale + ) + ) + ), + name = if (ep.episode_number > 0 && ep.episode.isNumeric()) { + "Season ${seasonData.season_number} Ep ${df.format(ep.episode_number)}: " + ep.title + } else { + ep.title + }, + episode_number = ep.episode_number, + date_upload = ep.airDate?.let { parseDate(it) } ?: 0L, + scanlator = ep.versions?.sortedBy { it.audio_locale } + ?.joinToString { it.audio_locale.substringBefore("-") } + ?: ep.audio_locale.substringBefore("-") ) - ) - ).toJsonString() - name = if (ep.episode_number > 0 && ep.episode.isNumeric()) { - "Season ${seasonData.season_number} Ep ${df.format(ep.episode_number)}: " + ep.title - } else { ep.title } - episode_number = ep.episode_number - date_upload = ep.airDate?.let { parseDate(it) } ?: 0L - scanlator = ep.versions?.sortedBy { it.audio_locale } - ?.joinToString { it.audio_locale.substringBefore("-") } ?: ep.audio_locale.substringBefore("-") - } + } + }.getOrNull() + }.asSequence().filterNotNull().flatten().groupBy { it.episode_number } + .map { (_, eList) -> + val versions = EpisodeData(eList.parallelMap { it.epData.ids }.flatten()).toJsonString() + val ep = eList.first() + SEpisode.create().apply { + this.url = versions + this.name = ep.name + this.episode_number = ep.episode_number + this.date_upload = ep.date_upload + this.scanlator = eList.map { it.scanlator }.joinToString() + } + } + }.flatten().reversed() + } else { + seasons.data.mapIndexed { index, movie -> + SEpisode.create().apply { + this.url = EpisodeData(listOf(Pair(movie.id, ""))).toJsonString() + this.name = "Movie" + this.episode_number = (index + 1).toFloat() + this.date_upload = movie.date?.let { parseDate(it) } ?: 0L } - }.getOrNull() - }.filterNotNull().flatten().reversed() + } + } } // ============================ Video Links ============================= @@ -166,11 +240,9 @@ class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { override fun fetchVideoList(episode: SEpisode): Observable> { val urlJson = json.decodeFromString(episode.url) val dubLocale = preferences.getString("preferred_audio", "en-US")!! - val tokenJson = preferences.getString(AccessTokenInterceptor.TOKEN_PREF_KEY, null) - ?: TokenInterceptor.refreshAccessToken() - val policyJson = json.decodeFromString(tokenJson) + val policyJson = tokenInterceptor.getAccessToken() val videoList = urlJson.ids.filter { - it.second == dubLocale || it.second == "ja-JP" || it.second == "en-US" + it.second == dubLocale || it.second == "ja-JP" || it.second == "en-US" || it.second == "" }.parallelMap { media -> runCatching { extractVideo(media, policyJson) @@ -183,7 +255,7 @@ class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() { // ============================= Utilities ============================== private fun extractVideo(media: Pair, policyJson: AccessToken): List