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