diff --git a/src/all/kamyroll/AndroidManifest.xml b/src/all/kamyroll/AndroidManifest.xml new file mode 100644 index 000000000..acb4de356 --- /dev/null +++ b/src/all/kamyroll/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/src/all/kamyroll/build.gradle b/src/all/kamyroll/build.gradle new file mode 100644 index 000000000..447abce39 --- /dev/null +++ b/src/all/kamyroll/build.gradle @@ -0,0 +1,17 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Kamyroll' + pkgNameSuffix = 'all.kamyroll' + extClass = '.Kamyroll' + extVersionCode = 1 + libVersion = '13' +} + +dependencies { + compileOnly libs.bundles.coroutines +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/kamyroll/res/mipmap-anydpi-v26/ic_launcher.xml b/src/all/kamyroll/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..90f958096 --- /dev/null +++ b/src/all/kamyroll/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..4d537d555 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-hdpi/ic_launcher_adaptive_back.png b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher_adaptive_back.png new file mode 100644 index 000000000..4ea92bd5e Binary files /dev/null and b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher_adaptive_back.png differ diff --git a/src/all/kamyroll/res/mipmap-hdpi/ic_launcher_adaptive_fore.png b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 000000000..53b306e9f Binary files /dev/null and b/src/all/kamyroll/res/mipmap-hdpi/ic_launcher_adaptive_fore.png differ diff --git a/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..0db821198 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-mdpi/ic_launcher_adaptive_back.png b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher_adaptive_back.png new file mode 100644 index 000000000..8d66ef758 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher_adaptive_back.png differ diff --git a/src/all/kamyroll/res/mipmap-mdpi/ic_launcher_adaptive_fore.png b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 000000000..014f44231 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-mdpi/ic_launcher_adaptive_fore.png differ diff --git a/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..0f3ea4d94 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher_adaptive_back.png b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 000000000..156f64239 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher_adaptive_back.png differ diff --git a/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 000000000..95bba295d Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xhdpi/ic_launcher_adaptive_fore.png differ diff --git a/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..be304e340 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 000000000..0425f0ad0 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher_adaptive_back.png differ diff --git a/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 000000000..9c4293100 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..4f680e21c Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png new file mode 100644 index 000000000..8ebe16407 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher_adaptive_back.png differ diff --git a/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png new file mode 100644 index 000000000..e02ba1db7 Binary files /dev/null and b/src/all/kamyroll/res/mipmap-xxxhdpi/ic_launcher_adaptive_fore.png differ diff --git a/src/all/kamyroll/res/web_hi_res_512.png b/src/all/kamyroll/res/web_hi_res_512.png new file mode 100644 index 000000000..83e560e63 Binary files /dev/null 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 new file mode 100644 index 000000000..f0077817e --- /dev/null +++ b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/AccessTokenInterceptor.kt @@ -0,0 +1,73 @@ +package eu.kanade.tachiyomi.animeextension.all.kamyroll + +import android.content.SharedPreferences +import eu.kanade.tachiyomi.network.POST +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.FormBody +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.net.HttpURLConnection + +class AccessTokenInterceptor(val baseUrl: String, val json: Json, val preferences: SharedPreferences) : Interceptor { + private val deviceId = randomId() + + override fun intercept(chain: Interceptor.Chain): Response { + val accessToken = getAccessToken() + val request = chain.request().newBuilder() + .header("authorization", accessToken) + .build() + val response = chain.proceed(request) + + if (response.code == HttpURLConnection.HTTP_UNAUTHORIZED) { + synchronized(this) { + response.close() + val newAccessToken = refreshAccessToken() + // Access token is refreshed in another thread. + if (accessToken != newAccessToken) { + return chain.proceed(newRequestWithAccessToken(request, newAccessToken)) + } + + // Need to refresh an access token + val updatedAccessToken = refreshAccessToken() + // Retry the request + return chain.proceed(newRequestWithAccessToken(request, updatedAccessToken)) + } + } + + return response + } + + private fun newRequestWithAccessToken(request: Request, accessToken: String): Request { + return request.newBuilder() + .header("authorization", accessToken) + .build() + } + + private fun getAccessToken(): String { + return preferences.getString("access_token", null) ?: "" + } + + private fun refreshAccessToken(): String { + val client = OkHttpClient().newBuilder().build() + val formData = FormBody.Builder() + .add("device_id", deviceId) + .add("device_type", "aniyomi") + .add("access_token", "HMbQeThWmZq4t7w") + .build() + val response = client.newCall(POST(url = "$baseUrl/auth/v1/token", body = formData)).execute() + val parsedJson = json.decodeFromString(response.body!!.string()) + val token = "${parsedJson.token_type} ${parsedJson.access_token}" + preferences.edit().putString("access_token", token).apply() + return token + } + + // Random 15 length string + private fun randomId(): String { + return (0..14).joinToString("") { + (('0'..'9') + ('a'..'f')).random().toString() + } + } +} 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 new file mode 100644 index 000000000..6fac71019 --- /dev/null +++ b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/DataModel.kt @@ -0,0 +1,167 @@ +package eu.kanade.tachiyomi.animeextension.all.kamyroll + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AccessToken( + val access_token: String, + val token_type: String, +) + +@Serializable +data class LinkData( + val id: String, + val media_type: String +) + +@Serializable +data class Images( + val thumbnail: ArrayList?, + val poster_tall: ArrayList?, + val poster_wide: ArrayList? +) { + @Serializable + data class Image( + val width: Int, + val height: Int, + val type: String, + val source: String + ) +} + +@Serializable +data class Metadata( + val is_dubbed: Boolean, + val is_mature: Boolean, + val is_subbed: Boolean, + val maturity_ratings: String, + val episode_count: Int?, + val is_simulcast: Boolean?, + val season_count: Int? +) + +@Serializable +data class Updated( + val total: Int, + val items: ArrayList +) { + @Serializable + data class Item( + val id: String, + val series_id: String, + val series_title: String, + val description: String, + val images: Images + ) +} + +@Serializable +data class SearchResult( + val total: Int, + val items: ArrayList +) { + @Serializable + data class SearchItem( + val type: String, + val total: Int, + val items: ArrayList + ) { + @Serializable + data class Item( + val id: String, + val description: String, + val media_type: String, + val title: String, + val images: Images, + val series_metadata: Metadata?, + val movie_listing_metadata: Metadata? + ) + } +} + +@Serializable +data class EpisodeList( + val total: Int, + val items: ArrayList +) { + @Serializable + data class Item( + @SerialName("__class__") + val media_class: String, + val id: String, + val type: String?, + val is_subbed: Boolean?, + val is_dubbed: Boolean?, + val episodes: ArrayList? + ) { + @Serializable + data class Episode( + val id: String, + val title: String, + val season_number: Int, + val sequence_number: Int, + val is_subbed: Boolean, + val is_dubbed: Boolean, + @SerialName("episode_air_date") + val air_date: String + ) + } +} + +@Serializable +data class MediaResult( + val id: String, + val title: String, + val description: String, + val images: Images, + val maturity_ratings: String, + val content_provider: String, + val is_mature: Boolean, + val is_subbed: Boolean, + val is_dubbed: Boolean, + val episode_count: Int?, + val season_count: Int?, + val media_count: Int?, + val is_simulcast: Boolean? +) + +@Serializable +data class RawEpisode( + val id: String, + val title: String, + val season: Int, + val episode: Int, + val air_date: String +) + +@Serializable +data class EpisodeData( + val ids: List +) + +@Serializable +data class VideoStreams( + val streams: List, + val subtitles: List +) { + @Serializable + data class Stream( + @SerialName("audio_locale") + val audio: String, + @SerialName("hardsub_locale") + val hardsub: String, + val url: String + ) + + @Serializable + data class Subtitle( + val locale: String, + val url: String + ) +} + +fun List.thirdLast(): T { + if (size < 3) throw NoSuchElementException("List has less than three elements") + return this[size - 3] +} diff --git a/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Kamyroll.kt b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Kamyroll.kt new file mode 100644 index 000000000..f8091962d --- /dev/null +++ b/src/all/kamyroll/src/eu/kanade/tachiyomi/animeextension/all/kamyroll/Kamyroll.kt @@ -0,0 +1,372 @@ +package eu.kanade.tachiyomi.animeextension.all.kamyroll + +import android.app.Application +import android.content.SharedPreferences +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource +import eu.kanade.tachiyomi.animesource.model.AnimeFilterList +import eu.kanade.tachiyomi.animesource.model.AnimesPage +import eu.kanade.tachiyomi.animesource.model.SAnime +import eu.kanade.tachiyomi.animesource.model.SEpisode +import eu.kanade.tachiyomi.animesource.model.Track +import eu.kanade.tachiyomi.animesource.model.Video +import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource +import eu.kanade.tachiyomi.network.GET +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy +import java.text.SimpleDateFormat +import java.util.Locale + +@ExperimentalSerializationApi +class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() { + + override val name = "Kamyroll" + + override val baseUrl by lazy { preferences.getString("preferred_domain", "https://api.kamyroll.tech")!! } + + override val lang = "all" + + override val supportsLatest = false + + private val json: Json by injectLazy() + + private val channelId = "crunchyroll" + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + override val client: OkHttpClient = OkHttpClient().newBuilder() + .addInterceptor(AccessTokenInterceptor(baseUrl, json, preferences)).build() + + companion object { + private val DateFormatter by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH) + } + } + + // ============================== Popular =============================== + + override fun popularAnimeRequest(page: Int): Request = + GET("$baseUrl/content/v1/updated?channel_id=$channelId&limit=20") + + override fun popularAnimeParse(response: Response): AnimesPage { + val parsed = json.decodeFromString(response.body!!.string()) + val animeList = parsed.items.map { ani -> + SAnime.create().apply { + title = ani.series_title + thumbnail_url = ani.images.poster_tall!!.thirdLast().source + url = LinkData(ani.series_id, "series").toJsonString() + description = ani.description + } + } + return AnimesPage(animeList, false) + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request = throw Exception("not used") + + override fun latestUpdatesParse(response: Response): AnimesPage = throw Exception("not used") + + // =============================== Search =============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val cleanQuery = query.replace(" ", "+").lowercase() + return GET("$baseUrl/content/v1/search?query=$cleanQuery&channel_id=$channelId") + } + + override fun searchAnimeParse(response: Response): AnimesPage { + val parsed = json.decodeFromString(response.body!!.string()) + val animeList = parsed.items.map { media -> + media.items.map { ani -> + SAnime.create().apply { + title = ani.title + thumbnail_url = ani.images.poster_tall!!.thirdLast().source + url = LinkData(ani.id, ani.media_type).toJsonString() + description = ani.description + } + } + }.flatten() + return AnimesPage(animeList, false) + } + + // =========================== Anime Details ============================ + + override fun fetchAnimeDetails(anime: SAnime): Observable { + val mediaId = json.decodeFromString(anime.url) + val response = client.newCall( + GET("$baseUrl/content/v1/media?id=${mediaId.id}&channel_id=$channelId") + ).execute() + return Observable.just(animeDetailsParse(response)) + } + + override fun animeDetailsParse(response: Response): SAnime { + val media = json.decodeFromString(response.body!!.string()) + val anime = SAnime.create() + anime.title = media.title + anime.author = media.content_provider + anime.status = SAnime.COMPLETED + + var description = media.description + "\n" + + description += "\nLanguage: Sub" + (if (media.is_dubbed) " Dub" else "") + + description += "\nMaturity Ratings: ${media.maturity_ratings}" + + description += if (media.is_simulcast!!) "\nSimulcast" else "" + + anime.description = description + + return anime + } + + // ============================== Episodes ============================== + + override fun episodeListRequest(anime: SAnime): Request { + val mediaId = json.decodeFromString(anime.url) + val path = if (mediaId.media_type == "series") "seasons" else "movies" + return GET("$baseUrl/content/v1/$path?id=${mediaId.id}&channel_id=$channelId") + } + + override fun episodeListParse(response: Response): List { + val medias = json.decodeFromString(response.body!!.string()) + + if (medias.items.first().media_class == "movie") { + return medias.items.map { media -> + SEpisode.create().apply { + url = media.id + name = "Movie" + episode_number = 0F + } + } + } else { + val rawEpsiodes = medias.items.map { season -> + season.episodes!!.map { + RawEpisode( + it.id, + it.title, + it.season_number, + it.sequence_number, + it.air_date + ) + } + }.flatten() + + return rawEpsiodes.groupBy { "${it.season}_${it.episode}" } + .mapNotNull { group -> + val (season, episode) = group.key.split("_") + SEpisode.create().apply { + url = EpisodeData(group.value.map { it.id }).toJsonString() + name = if (episode.toInt() > 0) "Season $season Ep $episode: " + group.value.first().title else group.value.first().title + episode_number = episode.toFloatOrNull() ?: 0F + date_upload = parseDate(group.value.first().air_date) + } + }.reversed() + } + } + + // ============================ Video Links ============================= + + override fun fetchVideoList(episode: SEpisode): Observable> { + val urlJson = json.decodeFromString(episode.url) + val videoList = urlJson.ids.parallelMap { vidId -> + runCatching { + extractVideo(vidId) + }.getOrNull() + } + .filterNotNull() + .flatten() + return Observable.just(videoList.sort()) + } + + // ============================= Utilities ============================== + + private fun extractVideo(vidId: String): List