Add Source Kamyroll (#1128)

Closes https://github.com/jmir1/aniyomi-extensions/issues/400
This commit is contained in:
Samfun75
2023-01-03 13:12:58 +03:00
committed by GitHub
parent 1cac66b5d4
commit 07ac55f953
22 changed files with 636 additions and 0 deletions

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest package="eu.kanade.tachiyomi.animeextension" />

View File

@ -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"

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_adaptive_back"/>
<foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -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<AccessToken>(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()
}
}
}

View File

@ -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<Image>?,
val poster_tall: ArrayList<Image>?,
val poster_wide: ArrayList<Image>?
) {
@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<Item>
) {
@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<SearchItem>
) {
@Serializable
data class SearchItem(
val type: String,
val total: Int,
val items: ArrayList<Item>
) {
@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<Item>
) {
@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<Episode>?
) {
@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<String>
)
@Serializable
data class VideoStreams(
val streams: List<Stream>,
val subtitles: List<Subtitle>
) {
@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 <T> List<T>.thirdLast(): T {
if (size < 3) throw NoSuchElementException("List has less than three elements")
return this[size - 3]
}

View File

@ -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<Application>().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<Updated>(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<SearchResult>(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<SAnime> {
val mediaId = json.decodeFromString<LinkData>(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<MediaResult>(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<LinkData>(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<SEpisode> {
val medias = json.decodeFromString<EpisodeList>(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<List<Video>> {
val urlJson = json.decodeFromString<EpisodeData>(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<Video> {
val url = "$baseUrl/videos/v1/streams?channel_id=$channelId&id=$vidId&type=adaptive_hls"
val response = client.newCall(GET(url)).execute()
val streams = json.decodeFromString<VideoStreams>(response.body!!.string())
val subsList = mutableListOf<Track>()
try {
streams.subtitles.forEach { sub ->
subsList.add(
Track(
sub.url,
sub.locale.getLocale()
)
)
}
} catch (_: Error) {}
return streams.streams.parallelMap { stream ->
runCatching {
val playlist = client.newCall(GET(stream.url)).execute().body!!.string()
playlist.substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").map {
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p" +
(if (stream.audio.getLocale().isNotBlank()) " - Aud: ${stream.audio.getLocale()}" else "") +
(if (stream.hardsub.getLocale().isNotBlank()) " - HardSub: ${stream.hardsub}" else "")
val videoUrl = it.substringAfter("\n").substringBefore("\n")
try {
Video(videoUrl, quality, videoUrl, subtitleTracks = subsList)
} catch (e: Error) {
Video(videoUrl, quality, videoUrl)
}
}
}.getOrNull()
}
.filterNotNull()
.flatten()
}
private fun String.getLocale(): String {
return locale.firstOrNull { it.first == this }?.second ?: ""
}
private val locale = arrayOf(
Pair("ar-ME", "Arabic"),
Pair("ar-SA", "Arabic (Saudi Arabia)"),
Pair("de-DE", "Dutch"),
Pair("en-US", "English"),
Pair("es-419", "Spanish"),
Pair("es-ES", "Spanish (Spain)"),
Pair("es-LA", "Spanish (Spanish)"),
Pair("fr-FR", "French"),
Pair("ja-JP", "Japanese"),
Pair("it-IT", "Italian"),
Pair("pt-BR", "Portuguese (Brazil)"),
Pair("pl-PL", "Polish"),
Pair("ru-RU", "Russian"),
Pair("tr-TR", "Turkish"),
Pair("uk-UK", "Ukrainian"),
Pair("he-IL", "Hebrew"),
Pair("ro-RO", "Romanian"),
Pair("sv-SE", "Swedish")
)
private fun LinkData.toJsonString(): String {
return json.encodeToString(this)
}
private fun EpisodeData.toJsonString(): String {
return json.encodeToString(this)
}
private fun parseDate(dateStr: String): Long {
return runCatching { DateFormatter.parse(dateStr)?.time }
.getOrNull() ?: 0L
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString("preferred_quality", null)
val dubLocale = preferences.getString("preferred_audio", null)
val newDubSortList = mutableListOf<Video>()
if (dubLocale != null) {
var preferred = 0
val dubLang = dubLocale.getLocale()
for (video in this) {
if (video.quality.contains(dubLang)) {
newDubSortList.add(preferred, video)
preferred++
} else {
newDubSortList.add(video)
}
}
} else {
newDubSortList.addAll(this)
}
val newList = mutableListOf<Video>()
if (quality != null) {
var preferred = 0
for (video in newDubSortList) {
if (video.quality.contains(quality)) {
newList.add(preferred, video)
preferred++
} else {
newList.add(video)
}
}
} else {
newList.addAll(newDubSortList)
}
return newList
}
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val domainPref = ListPreference(screen.context).apply {
key = "preferred_domain"
title = "Preferred domain (requires app restart)"
entries = arrayOf("kamyroll.tech")
entryValues = arrayOf("https://api.kamyroll.tech")
setDefaultValue("https://api.kamyroll.tech")
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
val videoQualityPref = ListPreference(screen.context).apply {
key = "preferred_quality"
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p", "240p", "80p")
entryValues = arrayOf("1080", "720", "480", "360", "240", "80")
setDefaultValue("1080")
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
val audLocalePref = ListPreference(screen.context).apply {
key = "preferred_audio"
title = "Preferred Audio Language"
entries = locale.map { it.second }.toTypedArray()
entryValues = locale.map { it.first }.toTypedArray()
setDefaultValue("en-US")
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
preferences.edit().putString(key, entry).commit()
}
}
screen.addPreference(domainPref)
screen.addPreference(videoQualityPref)
screen.addPreference(audLocalePref)
}
// From Dopebox
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
}