Kamyroll -> Consumyroll (#1247)
* Kamyroll -> Consumyroll * Remove locale translation for hardsub * Remove auto from softsub quality * Rename only * add content provider and list audio & sub versions * move episode list to official cr api update locales list audio and sub versions in description fix some inconsistencies
@ -3,10 +3,10 @@ apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Kamyroll'
|
||||
extName = 'Consumyroll'
|
||||
pkgNameSuffix = 'all.kamyroll'
|
||||
extClass = '.Kamyroll'
|
||||
extVersionCode = 8
|
||||
extClass = '.Consumyroll'
|
||||
extVersionCode = 9
|
||||
libVersion = '13'
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 29 KiB |
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 114 KiB |
BIN
src/all/kamyroll/res/play_store_512.png
Normal file
After Width: | Height: | Size: 191 KiB |
Before Width: | Height: | Size: 58 KiB |
@ -1,51 +1,57 @@
|
||||
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.json.Json
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
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.HttpURLConnection
|
||||
|
||||
class AccessTokenInterceptor(val baseUrl: String, val json: Json, val preferences: SharedPreferences) : Interceptor {
|
||||
private val deviceId = randomId()
|
||||
class AccessTokenInterceptor(
|
||||
private val json: Json,
|
||||
private val preferences: SharedPreferences
|
||||
) : Interceptor {
|
||||
private var accessToken = preferences.getString("access_token", null) ?: ""
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
if (accessToken.isBlank()) accessToken = refreshAccessToken()
|
||||
|
||||
val request = if (chain.request().url.toString().contains("kamyroll")) {
|
||||
chain.request().newBuilder()
|
||||
.header("authorization", accessToken)
|
||||
.build()
|
||||
} else {
|
||||
chain.request()
|
||||
}
|
||||
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) {
|
||||
accessToken = newAccessToken
|
||||
return chain.proceed(newRequestWithAccessToken(chain.request(), newAccessToken))
|
||||
when (response.code) {
|
||||
HttpURLConnection.HTTP_UNAUTHORIZED -> {
|
||||
synchronized(this) {
|
||||
response.close()
|
||||
val newAccessToken = refreshAccessToken()
|
||||
// Access token is refreshed in another thread.
|
||||
if (accessToken != newAccessToken) {
|
||||
accessToken = newAccessToken
|
||||
return chain.proceed(
|
||||
newRequestWithAccessToken(chain.request(), newAccessToken)
|
||||
)
|
||||
}
|
||||
|
||||
// Need to refresh an access token
|
||||
val updatedAccessToken = refreshAccessToken()
|
||||
accessToken = updatedAccessToken
|
||||
// Retry the request
|
||||
return chain.proceed(
|
||||
newRequestWithAccessToken(chain.request(), updatedAccessToken)
|
||||
)
|
||||
}
|
||||
|
||||
// Need to refresh an access token
|
||||
val updatedAccessToken = refreshAccessToken()
|
||||
accessToken = updatedAccessToken
|
||||
// Retry the request
|
||||
return chain.proceed(newRequestWithAccessToken(chain.request(), updatedAccessToken))
|
||||
}
|
||||
else -> return response
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
private fun newRequestWithAccessToken(request: Request, accessToken: String): Request {
|
||||
@ -56,22 +62,17 @@ class AccessTokenInterceptor(val baseUrl: String, val json: Json, val preference
|
||||
|
||||
private fun refreshAccessToken(): String {
|
||||
val client = OkHttpClient().newBuilder().build()
|
||||
val url = "$baseUrl/auth/v1/token".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("device_id", deviceId)
|
||||
.addQueryParameter("device_type", "aniyomi")
|
||||
.addQueryParameter("access_token", "HMbQeThWmZq4t7w")
|
||||
.build()
|
||||
val response = client.newCall(GET(url.toString())).execute()
|
||||
val parsedJson = json.decodeFromString<AccessToken>(response.body!!.string())
|
||||
val headers = Headers.headersOf(
|
||||
"Content-Type", "application/x-www-form-urlencoded",
|
||||
"User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:106.0) Gecko/20100101 Firefox/106.0",
|
||||
"Authorization", "Basic a3ZvcGlzdXZ6Yy0teG96Y21kMXk6R21JSTExenVPVnRnTjdlSWZrSlpibzVuLTRHTlZ0cU8="
|
||||
)
|
||||
val postBody = "grant_type=client_id".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||
val response = client.newCall(POST("https://beta-api.crunchyroll.com/auth/v1/token", headers, postBody)).execute()
|
||||
val respBody = response.body!!.string()
|
||||
val parsedJson = json.decodeFromString<AccessToken>(respBody)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -33,74 +33,71 @@ import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
class Consumyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
|
||||
override val name = "Kamyroll"
|
||||
override val name = "Consumyroll"
|
||||
|
||||
override val baseUrl by lazy { preferences.getString("preferred_domain", "https://api.kamyroll.tech")!! }
|
||||
override val baseUrl = "https://cronchy.consumet.stream"
|
||||
|
||||
private val crUrl = "https://beta-api.crunchyroll.com"
|
||||
|
||||
override val lang = "all"
|
||||
|
||||
override val supportsLatest = false
|
||||
override val supportsLatest = true
|
||||
|
||||
override val id: Long = 7463514907068706782
|
||||
|
||||
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()
|
||||
.addInterceptor(AccessTokenInterceptor(json, preferences)).build()
|
||||
|
||||
companion object {
|
||||
private val DateFormatter by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.ENGLISH)
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ENGLISH)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================== Popular ===============================
|
||||
|
||||
override fun popularAnimeRequest(page: Int): Request =
|
||||
GET("$baseUrl/content/v1/updated?channel_id=$channelId&limit=20")
|
||||
override fun popularAnimeRequest(page: Int): Request {
|
||||
val start = if (page != 1) "start=${(page - 1) * 36}&" else ""
|
||||
return GET("$crUrl/content/v2/discover/browse?${start}n=36&sort_by=popularity&locale=en-US")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
val parsed = json.decodeFromString<AnimeResult>(response.body!!.string())
|
||||
val animeList = parsed.data.filter { it.type == "series" }.map { ani ->
|
||||
ani.toSAnime()
|
||||
}
|
||||
return AnimesPage(animeList, false)
|
||||
return AnimesPage(animeList, true)
|
||||
}
|
||||
|
||||
// =============================== Latest ===============================
|
||||
|
||||
override fun latestUpdatesRequest(page: Int): Request = throw Exception("not used")
|
||||
override fun latestUpdatesRequest(page: Int): Request {
|
||||
val start = if (page != 1) "start=${(page - 1) * 36}&" else ""
|
||||
return GET("$crUrl/content/v2/discover/browse?${start}n=36&sort_by=newly_added&locale=en-US")
|
||||
}
|
||||
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage = throw Exception("not used")
|
||||
override fun latestUpdatesParse(response: Response): AnimesPage = popularAnimeParse(response)
|
||||
|
||||
// =============================== 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")
|
||||
return GET("$crUrl/content/v2/discover/search?q=$cleanQuery&n=6&type=&locale=en-US")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
val parsed = json.decodeFromString<SearchAnimeResult>(response.body!!.string())
|
||||
val animeList = parsed.data.filter { it.type == "top_results" }.map { result ->
|
||||
result.items.filter { it.type == "series" }.map { ani ->
|
||||
ani.toSAnime()
|
||||
}
|
||||
}.flatten()
|
||||
return AnimesPage(animeList, false)
|
||||
@ -110,85 +107,49 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
|
||||
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))
|
||||
val resp = client.newCall(GET("$crUrl/content/v2/cms/series/${mediaId.id}?locale=en-US")).execute()
|
||||
val info = json.decodeFromString<AnimeResult>(resp.body!!.string())
|
||||
return Observable.just(anime.apply { author = info.data.first().content_provider })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
override fun animeDetailsParse(response: Response): SAnime = throw Exception("not used")
|
||||
|
||||
// ============================== 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")
|
||||
return GET("$crUrl/content/v2/cms/series/${mediaId.id}/seasons")
|
||||
}
|
||||
|
||||
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("_")
|
||||
val ep = episode.toFloatOrNull() ?: 0F
|
||||
val seasons = json.decodeFromString<SeasonResult>(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<EpisodeResult>(episodeResp.body!!.string())
|
||||
episodes.data.sortedBy { it.episode_number }.map { ep ->
|
||||
SEpisode.create().apply {
|
||||
url = EpisodeData(group.value.map { it.id }).toJsonString()
|
||||
name = if (ep > 0) "Season $season Ep ${df.format(ep)}: " + group.value.first().title else group.value.first().title
|
||||
episode_number = ep
|
||||
date_upload = parseDate(group.value.first().air_date)
|
||||
url = EpisodeData(
|
||||
ep.versions.map { Pair(it.id, it.audio_locale) }
|
||||
).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 = parseDate(ep.airDate)
|
||||
}
|
||||
}.reversed()
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
}.filterNotNull().flatten().reversed()
|
||||
}
|
||||
|
||||
// ============================ Video Links =============================
|
||||
|
||||
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
|
||||
val urlJson = json.decodeFromString<EpisodeData>(episode.url)
|
||||
val videoList = urlJson.ids.parallelMap { vidId ->
|
||||
val videoList = urlJson.ids.parallelMap { media ->
|
||||
runCatching {
|
||||
extractVideo(vidId)
|
||||
extractVideo(media)
|
||||
}.getOrNull()
|
||||
}
|
||||
.filterNotNull()
|
||||
@ -198,48 +159,53 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
|
||||
// ============================= 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()
|
||||
private fun extractVideo(media: Pair<String, String>): List<Video> {
|
||||
val (vidId, audLang) = media
|
||||
val response = client.newCall(GET("$baseUrl/episode/$vidId")).execute()
|
||||
val streams = json.decodeFromString<VideoStreams>(response.body!!.string())
|
||||
|
||||
val subsList = mutableListOf<Track>()
|
||||
val subLocale = preferences.getString("preferred_sub", "en-US")!!
|
||||
var subPreferred = 0
|
||||
var subsList = emptyList<Track>()
|
||||
val subLocale = preferences.getString("preferred_sub", "en-US")!!.getLocale()
|
||||
try {
|
||||
streams.subtitles.forEach { sub ->
|
||||
if (sub.locale == subLocale) {
|
||||
subsList.add(
|
||||
subPreferred,
|
||||
Track(sub.url, sub.locale.getLocale())
|
||||
)
|
||||
subPreferred++
|
||||
} else {
|
||||
subsList.add(
|
||||
Track(sub.url, sub.locale.getLocale())
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (_: Error) { }
|
||||
subsList = streams.subtitles.map { sub ->
|
||||
Track(sub.url, sub.lang.getLocale())
|
||||
}.sortedWith(
|
||||
compareBy(
|
||||
{ it.lang },
|
||||
{ it.lang.contains(subLocale) }
|
||||
)
|
||||
)
|
||||
} 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")
|
||||
return streams.sources.filter { it.quality.contains("auto") || it.quality.contains("hardsub") }
|
||||
.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 hardsub = stream.quality.replace("hardsub", "").replace("auto", "").trim()
|
||||
.let { hs ->
|
||||
if (hs.isNotBlank()) " - HardSub: $hs" else ""
|
||||
}
|
||||
val quality = it.substringAfter("RESOLUTION=")
|
||||
.split(",")[0].split("\n")[0].substringAfter("x") +
|
||||
"p - Aud: ${audLang.getLocale()}$hardsub"
|
||||
|
||||
try {
|
||||
Video(videoUrl, quality, videoUrl, subtitleTracks = if (stream.hardsub.getLocale().isNotBlank()) emptyList() else subsList)
|
||||
} catch (e: Error) {
|
||||
Video(videoUrl, quality, videoUrl)
|
||||
val videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||
|
||||
try {
|
||||
Video(
|
||||
videoUrl,
|
||||
quality,
|
||||
videoUrl,
|
||||
subtitleTracks = if (hardsub.isNotBlank()) emptyList() else subsList
|
||||
)
|
||||
} catch (_: Error) {
|
||||
Video(videoUrl, quality, videoUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
.filterNotNull()
|
||||
.flatten()
|
||||
}
|
||||
@ -250,25 +216,34 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
return locale.firstOrNull { it.first == this }?.second ?: ""
|
||||
}
|
||||
|
||||
private fun String.isNumeric(): Boolean {
|
||||
return this@isNumeric.toDoubleOrNull() != null
|
||||
}
|
||||
|
||||
private val locale = arrayOf(
|
||||
Pair("ar-ME", "Arabic"),
|
||||
Pair("ar-SA", "Arabic (Saudi Arabia)"),
|
||||
Pair("de-DE", "German"),
|
||||
Pair("en-US", "English"),
|
||||
Pair("es-419", "Spanish"),
|
||||
Pair("es-ES", "Spanish (Spain)"),
|
||||
Pair("es-LA", "Spanish (Spanish)"),
|
||||
Pair("en-IN", "English (India)"),
|
||||
Pair("es-419", "Spanish (América Latina)"),
|
||||
Pair("es-ES", "Spanish (España)"),
|
||||
Pair("es-LA", "Spanish (América Latina)"),
|
||||
Pair("fr-FR", "French"),
|
||||
Pair("ja-JP", "Japanese"),
|
||||
Pair("hi-IN", "Hindi"),
|
||||
Pair("it-IT", "Italian"),
|
||||
Pair("pt-BR", "Portuguese (Brazil)"),
|
||||
Pair("ko-KR", "Korean"),
|
||||
Pair("pt-BR", "Português (Brasil)"),
|
||||
Pair("pt-PT", "Português (Portugal)"),
|
||||
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")
|
||||
Pair("sv-SE", "Swedish"),
|
||||
Pair("zh-CN", "Chinese")
|
||||
)
|
||||
|
||||
private fun LinkData.toJsonString(): String {
|
||||
@ -284,6 +259,23 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
.getOrNull() ?: 0L
|
||||
}
|
||||
|
||||
private fun Anime.toSAnime(): SAnime =
|
||||
SAnime.create().apply {
|
||||
title = this@toSAnime.title
|
||||
thumbnail_url = this@toSAnime.images.poster_tall?.getOrNull(0)?.thirdLast()?.source
|
||||
?: this@toSAnime.images.poster_tall?.getOrNull(0)?.last()?.source
|
||||
url = this@toSAnime.type!!.let { LinkData(this@toSAnime.id, it).toJsonString() }
|
||||
genre = this@toSAnime.series_metadata!!.genres?.joinToString() ?: "Anime"
|
||||
status = SAnime.COMPLETED
|
||||
var desc = this@toSAnime.description + "\n"
|
||||
desc += "\nLanguage: Sub" + (if (this@toSAnime.series_metadata.audio_locales.size > 1) " Dub" else "")
|
||||
desc += "\nMaturity Ratings: ${this@toSAnime.series_metadata.maturity_ratings.joinToString()}"
|
||||
desc += if (this@toSAnime.series_metadata.is_simulcast) "\nSimulcast" else ""
|
||||
desc += "\n\nAudio: " + this@toSAnime.series_metadata.audio_locales.joinToString { it.getLocale() }
|
||||
desc += "\n\nSubs: " + this@toSAnime.series_metadata.subtitle_locales.joinToString { it.getLocale() }
|
||||
description += desc
|
||||
}
|
||||
|
||||
override fun List<Video>.sort(): List<Video> {
|
||||
val quality = preferences.getString("preferred_quality", "1080")!!
|
||||
val dubLocale = preferences.getString("preferred_audio", "en-US")!!
|
||||
@ -302,22 +294,6 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
}
|
||||
|
||||
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"
|
||||
@ -381,8 +357,6 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
||||
preferences.edit().putString(key, entry).commit()
|
||||
}
|
||||
}
|
||||
|
||||
screen.addPreference(domainPref)
|
||||
screen.addPreference(videoQualityPref)
|
||||
screen.addPreference(audLocalePref)
|
||||
screen.addPreference(subLocalePref)
|
@ -17,7 +17,7 @@ data class LinkData(
|
||||
|
||||
@Serializable
|
||||
data class Images(
|
||||
val poster_tall: ArrayList<Image>? = null
|
||||
val poster_tall: List<ArrayList<Image>>? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Image(
|
||||
@ -29,137 +29,107 @@ data class Images(
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Metadata(
|
||||
val is_dubbed: Boolean,
|
||||
val is_mature: Boolean,
|
||||
val is_subbed: Boolean,
|
||||
val maturity_ratings: String,
|
||||
val episode_count: Int? = null,
|
||||
val is_simulcast: Boolean? = null,
|
||||
val season_count: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Updated(
|
||||
val total: Int,
|
||||
val items: ArrayList<Item>
|
||||
data class Anime(
|
||||
val id: String,
|
||||
val type: String? = null,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val images: Images,
|
||||
val series_metadata: Metadata? = null,
|
||||
val content_provider: String? = null,
|
||||
val audio_locales: ArrayList<String>? = null,
|
||||
val subtitle_locales: ArrayList<String>? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Item(
|
||||
val id: String,
|
||||
val series_id: String,
|
||||
val series_title: String,
|
||||
val description: String,
|
||||
val images: Images
|
||||
data class Metadata(
|
||||
val maturity_ratings: ArrayList<String>,
|
||||
val is_simulcast: Boolean,
|
||||
val audio_locales: ArrayList<String>,
|
||||
val subtitle_locales: ArrayList<String>,
|
||||
@SerialName("tenant_categories")
|
||||
val genres: ArrayList<String>?
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SearchResult(
|
||||
data class AnimeResult(
|
||||
val total: Int,
|
||||
val items: ArrayList<SearchItem>
|
||||
val data: ArrayList<Anime>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SearchAnimeResult(
|
||||
val total: Int,
|
||||
val data: ArrayList<Result>
|
||||
) {
|
||||
@Serializable
|
||||
data class SearchItem(
|
||||
data class Result(
|
||||
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? = null,
|
||||
val movie_listing_metadata: Metadata? = null
|
||||
)
|
||||
}
|
||||
val count: Int,
|
||||
val items: ArrayList<Anime>
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EpisodeList(
|
||||
data class SeasonResult(
|
||||
val total: Int,
|
||||
val items: ArrayList<Item>
|
||||
val data: ArrayList<Season>
|
||||
) {
|
||||
@Serializable
|
||||
data class Item(
|
||||
@SerialName("__class__")
|
||||
val media_class: String,
|
||||
data class Season(
|
||||
val id: String,
|
||||
val type: String? = null,
|
||||
val is_subbed: Boolean? = null,
|
||||
val is_dubbed: Boolean? = null,
|
||||
val episodes: ArrayList<Episode>? = null
|
||||
) {
|
||||
@Serializable
|
||||
data class Episode(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val season_number: Int,
|
||||
val sequence_number: Float,
|
||||
val is_subbed: Boolean,
|
||||
val is_dubbed: Boolean,
|
||||
@SerialName("episode_air_date")
|
||||
val air_date: String
|
||||
)
|
||||
}
|
||||
val season_number: Int
|
||||
)
|
||||
}
|
||||
|
||||
@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? = null,
|
||||
val season_count: Int? = null,
|
||||
val media_count: Int? = null,
|
||||
val is_simulcast: Boolean? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RawEpisode(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val season: Int,
|
||||
val episode: Float,
|
||||
val air_date: String
|
||||
)
|
||||
data class EpisodeResult(
|
||||
val total: Int,
|
||||
val data: ArrayList<Episode>
|
||||
) {
|
||||
@Serializable
|
||||
data class Episode(
|
||||
val title: String,
|
||||
@SerialName("sequence_number")
|
||||
val episode_number: Float,
|
||||
val episode: String,
|
||||
@SerialName("episode_air_date")
|
||||
val airDate: String,
|
||||
val versions: ArrayList<Version>
|
||||
) {
|
||||
@Serializable
|
||||
data class Version(
|
||||
val audio_locale: String,
|
||||
@SerialName("guid")
|
||||
val id: String
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class EpisodeData(
|
||||
val ids: List<String>
|
||||
val ids: List<Pair<String, String>>
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class VideoStreams(
|
||||
val streams: List<Stream>,
|
||||
val sources: 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
|
||||
val url: String,
|
||||
val quality: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Subtitle(
|
||||
val locale: String,
|
||||
val url: String
|
||||
val url: String,
|
||||
val lang: String
|
||||
)
|
||||
}
|
||||
|
||||
fun <T> List<T>.thirdLast(): T {
|
||||
if (size < 3) throw NoSuchElementException("List has less than three elements")
|
||||
fun <T> List<T>.thirdLast(): T? {
|
||||
if (size < 3) return null
|
||||
return this[size - 3]
|
||||
}
|
||||
|