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'
|
apply plugin: 'kotlinx-serialization'
|
||||||
|
|
||||||
ext {
|
ext {
|
||||||
extName = 'Kamyroll'
|
extName = 'Consumyroll'
|
||||||
pkgNameSuffix = 'all.kamyroll'
|
pkgNameSuffix = 'all.kamyroll'
|
||||||
extClass = '.Kamyroll'
|
extClass = '.Consumyroll'
|
||||||
extVersionCode = 8
|
extVersionCode = 9
|
||||||
libVersion = '13'
|
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
|
package eu.kanade.tachiyomi.animeextension.all.kamyroll
|
||||||
|
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import eu.kanade.tachiyomi.network.GET
|
import eu.kanade.tachiyomi.network.POST
|
||||||
import kotlinx.serialization.decodeFromString
|
import kotlinx.serialization.decodeFromString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.Headers
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
class AccessTokenInterceptor(val baseUrl: String, val json: Json, val preferences: SharedPreferences) : Interceptor {
|
class AccessTokenInterceptor(
|
||||||
private val deviceId = randomId()
|
private val json: Json,
|
||||||
|
private val preferences: SharedPreferences
|
||||||
|
) : Interceptor {
|
||||||
private var accessToken = preferences.getString("access_token", null) ?: ""
|
private var accessToken = preferences.getString("access_token", null) ?: ""
|
||||||
|
|
||||||
override fun intercept(chain: Interceptor.Chain): Response {
|
override fun intercept(chain: Interceptor.Chain): Response {
|
||||||
if (accessToken.isBlank()) accessToken = refreshAccessToken()
|
if (accessToken.isBlank()) accessToken = refreshAccessToken()
|
||||||
|
|
||||||
val request = if (chain.request().url.toString().contains("kamyroll")) {
|
val request = chain.request().newBuilder()
|
||||||
chain.request().newBuilder()
|
.header("authorization", accessToken)
|
||||||
.header("authorization", accessToken)
|
.build()
|
||||||
.build()
|
|
||||||
} else {
|
|
||||||
chain.request()
|
|
||||||
}
|
|
||||||
val response = chain.proceed(request)
|
val response = chain.proceed(request)
|
||||||
|
|
||||||
if (response.code == HttpURLConnection.HTTP_UNAUTHORIZED) {
|
when (response.code) {
|
||||||
synchronized(this) {
|
HttpURLConnection.HTTP_UNAUTHORIZED -> {
|
||||||
response.close()
|
synchronized(this) {
|
||||||
val newAccessToken = refreshAccessToken()
|
response.close()
|
||||||
// Access token is refreshed in another thread.
|
val newAccessToken = refreshAccessToken()
|
||||||
if (accessToken != newAccessToken) {
|
// Access token is refreshed in another thread.
|
||||||
accessToken = newAccessToken
|
if (accessToken != newAccessToken) {
|
||||||
return chain.proceed(newRequestWithAccessToken(chain.request(), 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 {
|
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 {
|
private fun refreshAccessToken(): String {
|
||||||
val client = OkHttpClient().newBuilder().build()
|
val client = OkHttpClient().newBuilder().build()
|
||||||
val url = "$baseUrl/auth/v1/token".toHttpUrlOrNull()!!.newBuilder()
|
val headers = Headers.headersOf(
|
||||||
.addQueryParameter("device_id", deviceId)
|
"Content-Type", "application/x-www-form-urlencoded",
|
||||||
.addQueryParameter("device_type", "aniyomi")
|
"User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:106.0) Gecko/20100101 Firefox/106.0",
|
||||||
.addQueryParameter("access_token", "HMbQeThWmZq4t7w")
|
"Authorization", "Basic a3ZvcGlzdXZ6Yy0teG96Y21kMXk6R21JSTExenVPVnRnTjdlSWZrSlpibzVuLTRHTlZ0cU8="
|
||||||
.build()
|
)
|
||||||
val response = client.newCall(GET(url.toString())).execute()
|
val postBody = "grant_type=client_id".toRequestBody("application/x-www-form-urlencoded".toMediaType())
|
||||||
val parsedJson = json.decodeFromString<AccessToken>(response.body!!.string())
|
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}"
|
val token = "${parsedJson.token_type} ${parsedJson.access_token}"
|
||||||
preferences.edit().putString("access_token", token).apply()
|
preferences.edit().putString("access_token", token).apply()
|
||||||
return token
|
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
|
import java.util.Locale
|
||||||
|
|
||||||
@ExperimentalSerializationApi
|
@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 lang = "all"
|
||||||
|
|
||||||
override val supportsLatest = false
|
override val supportsLatest = true
|
||||||
|
|
||||||
|
override val id: Long = 7463514907068706782
|
||||||
|
|
||||||
private val json: Json by injectLazy()
|
private val json: Json by injectLazy()
|
||||||
|
|
||||||
private val channelId = "crunchyroll"
|
|
||||||
|
|
||||||
private val preferences: SharedPreferences by lazy {
|
private val preferences: SharedPreferences by lazy {
|
||||||
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
|
||||||
}
|
}
|
||||||
|
|
||||||
override val client: OkHttpClient = OkHttpClient().newBuilder()
|
override val client: OkHttpClient = OkHttpClient().newBuilder()
|
||||||
.addInterceptor(AccessTokenInterceptor(baseUrl, json, preferences)).build()
|
.addInterceptor(AccessTokenInterceptor(json, preferences)).build()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val DateFormatter by lazy {
|
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 ===============================
|
// ============================== Popular ===============================
|
||||||
|
|
||||||
override fun popularAnimeRequest(page: Int): Request =
|
override fun popularAnimeRequest(page: Int): Request {
|
||||||
GET("$baseUrl/content/v1/updated?channel_id=$channelId&limit=20")
|
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 {
|
override fun popularAnimeParse(response: Response): AnimesPage {
|
||||||
val parsed = json.decodeFromString<Updated>(response.body!!.string())
|
val parsed = json.decodeFromString<AnimeResult>(response.body!!.string())
|
||||||
val animeList = parsed.items.map { ani ->
|
val animeList = parsed.data.filter { it.type == "series" }.map { ani ->
|
||||||
SAnime.create().apply {
|
ani.toSAnime()
|
||||||
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)
|
return AnimesPage(animeList, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================== Latest ===============================
|
// =============================== 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 ===============================
|
// =============================== Search ===============================
|
||||||
|
|
||||||
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
|
||||||
val cleanQuery = query.replace(" ", "+").lowercase()
|
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 {
|
override fun searchAnimeParse(response: Response): AnimesPage {
|
||||||
val parsed = json.decodeFromString<SearchResult>(response.body!!.string())
|
val parsed = json.decodeFromString<SearchAnimeResult>(response.body!!.string())
|
||||||
val animeList = parsed.items.map { media ->
|
val animeList = parsed.data.filter { it.type == "top_results" }.map { result ->
|
||||||
media.items.map { ani ->
|
result.items.filter { it.type == "series" }.map { ani ->
|
||||||
SAnime.create().apply {
|
ani.toSAnime()
|
||||||
title = ani.title
|
|
||||||
thumbnail_url = ani.images.poster_tall!!.thirdLast().source
|
|
||||||
url = LinkData(ani.id, ani.media_type).toJsonString()
|
|
||||||
description = ani.description
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.flatten()
|
}.flatten()
|
||||||
return AnimesPage(animeList, false)
|
return AnimesPage(animeList, false)
|
||||||
@ -110,85 +107,49 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
|||||||
|
|
||||||
override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
|
override fun fetchAnimeDetails(anime: SAnime): Observable<SAnime> {
|
||||||
val mediaId = json.decodeFromString<LinkData>(anime.url)
|
val mediaId = json.decodeFromString<LinkData>(anime.url)
|
||||||
val response = client.newCall(
|
val resp = client.newCall(GET("$crUrl/content/v2/cms/series/${mediaId.id}?locale=en-US")).execute()
|
||||||
GET("$baseUrl/content/v1/media?id=${mediaId.id}&channel_id=$channelId")
|
val info = json.decodeFromString<AnimeResult>(resp.body!!.string())
|
||||||
).execute()
|
return Observable.just(anime.apply { author = info.data.first().content_provider })
|
||||||
return Observable.just(animeDetailsParse(response))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun animeDetailsParse(response: Response): SAnime {
|
override fun animeDetailsParse(response: Response): SAnime = throw Exception("not used")
|
||||||
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 ==============================
|
// ============================== Episodes ==============================
|
||||||
|
|
||||||
override fun episodeListRequest(anime: SAnime): Request {
|
override fun episodeListRequest(anime: SAnime): Request {
|
||||||
val mediaId = json.decodeFromString<LinkData>(anime.url)
|
val mediaId = json.decodeFromString<LinkData>(anime.url)
|
||||||
val path = if (mediaId.media_type == "series") "seasons" else "movies"
|
return GET("$crUrl/content/v2/cms/series/${mediaId.id}/seasons")
|
||||||
return GET("$baseUrl/content/v1/$path?id=${mediaId.id}&channel_id=$channelId")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun episodeListParse(response: Response): List<SEpisode> {
|
override fun episodeListParse(response: Response): List<SEpisode> {
|
||||||
val medias = json.decodeFromString<EpisodeList>(response.body!!.string())
|
val seasons = json.decodeFromString<SeasonResult>(response.body!!.string())
|
||||||
|
return seasons.data.parallelMap { seasonData ->
|
||||||
if (medias.items.first().media_class == "movie") {
|
runCatching {
|
||||||
return medias.items.map { media ->
|
val episodeResp = client.newCall(GET("$crUrl/content/v2/cms/seasons/${seasonData.id}/episodes")).execute()
|
||||||
SEpisode.create().apply {
|
val episodes = json.decodeFromString<EpisodeResult>(episodeResp.body!!.string())
|
||||||
url = media.id
|
episodes.data.sortedBy { it.episode_number }.map { ep ->
|
||||||
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
|
|
||||||
SEpisode.create().apply {
|
SEpisode.create().apply {
|
||||||
url = EpisodeData(group.value.map { it.id }).toJsonString()
|
url = EpisodeData(
|
||||||
name = if (ep > 0) "Season $season Ep ${df.format(ep)}: " + group.value.first().title else group.value.first().title
|
ep.versions.map { Pair(it.id, it.audio_locale) }
|
||||||
episode_number = ep
|
).toJsonString()
|
||||||
date_upload = parseDate(group.value.first().air_date)
|
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 =============================
|
// ============================ Video Links =============================
|
||||||
|
|
||||||
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
|
override fun fetchVideoList(episode: SEpisode): Observable<List<Video>> {
|
||||||
val urlJson = json.decodeFromString<EpisodeData>(episode.url)
|
val urlJson = json.decodeFromString<EpisodeData>(episode.url)
|
||||||
val videoList = urlJson.ids.parallelMap { vidId ->
|
val videoList = urlJson.ids.parallelMap { media ->
|
||||||
runCatching {
|
runCatching {
|
||||||
extractVideo(vidId)
|
extractVideo(media)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
}
|
}
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
@ -198,48 +159,53 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
|||||||
|
|
||||||
// ============================= Utilities ==============================
|
// ============================= Utilities ==============================
|
||||||
|
|
||||||
private fun extractVideo(vidId: String): List<Video> {
|
private fun extractVideo(media: Pair<String, String>): List<Video> {
|
||||||
val url = "$baseUrl/videos/v1/streams?channel_id=$channelId&id=$vidId&type=adaptive_hls"
|
val (vidId, audLang) = media
|
||||||
val response = client.newCall(GET(url)).execute()
|
val response = client.newCall(GET("$baseUrl/episode/$vidId")).execute()
|
||||||
val streams = json.decodeFromString<VideoStreams>(response.body!!.string())
|
val streams = json.decodeFromString<VideoStreams>(response.body!!.string())
|
||||||
|
|
||||||
val subsList = mutableListOf<Track>()
|
var subsList = emptyList<Track>()
|
||||||
val subLocale = preferences.getString("preferred_sub", "en-US")!!
|
val subLocale = preferences.getString("preferred_sub", "en-US")!!.getLocale()
|
||||||
var subPreferred = 0
|
|
||||||
try {
|
try {
|
||||||
streams.subtitles.forEach { sub ->
|
subsList = streams.subtitles.map { sub ->
|
||||||
if (sub.locale == subLocale) {
|
Track(sub.url, sub.lang.getLocale())
|
||||||
subsList.add(
|
}.sortedWith(
|
||||||
subPreferred,
|
compareBy(
|
||||||
Track(sub.url, sub.locale.getLocale())
|
{ it.lang },
|
||||||
)
|
{ it.lang.contains(subLocale) }
|
||||||
subPreferred++
|
)
|
||||||
} else {
|
)
|
||||||
subsList.add(
|
} catch (_: Error) {}
|
||||||
Track(sub.url, sub.locale.getLocale())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Error) { }
|
|
||||||
|
|
||||||
return streams.streams.parallelMap { stream ->
|
return streams.sources.filter { it.quality.contains("auto") || it.quality.contains("hardsub") }
|
||||||
runCatching {
|
.parallelMap { stream ->
|
||||||
val playlist = client.newCall(GET(stream.url)).execute().body!!.string()
|
runCatching {
|
||||||
playlist.substringAfter("#EXT-X-STREAM-INF:")
|
val playlist = client.newCall(GET(stream.url)).execute().body!!.string()
|
||||||
.split("#EXT-X-STREAM-INF:").map {
|
playlist.substringAfter("#EXT-X-STREAM-INF:")
|
||||||
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p" +
|
.split("#EXT-X-STREAM-INF:").map {
|
||||||
(if (stream.audio.getLocale().isNotBlank()) " - Aud: ${stream.audio.getLocale()}" else "") +
|
val hardsub = stream.quality.replace("hardsub", "").replace("auto", "").trim()
|
||||||
(if (stream.hardsub.getLocale().isNotBlank()) " - HardSub: ${stream.hardsub}" else "")
|
.let { hs ->
|
||||||
val videoUrl = it.substringAfter("\n").substringBefore("\n")
|
if (hs.isNotBlank()) " - HardSub: $hs" else ""
|
||||||
|
}
|
||||||
|
val quality = it.substringAfter("RESOLUTION=")
|
||||||
|
.split(",")[0].split("\n")[0].substringAfter("x") +
|
||||||
|
"p - Aud: ${audLang.getLocale()}$hardsub"
|
||||||
|
|
||||||
try {
|
val videoUrl = it.substringAfter("\n").substringBefore("\n")
|
||||||
Video(videoUrl, quality, videoUrl, subtitleTracks = if (stream.hardsub.getLocale().isNotBlank()) emptyList() else subsList)
|
|
||||||
} catch (e: Error) {
|
try {
|
||||||
Video(videoUrl, quality, videoUrl)
|
Video(
|
||||||
|
videoUrl,
|
||||||
|
quality,
|
||||||
|
videoUrl,
|
||||||
|
subtitleTracks = if (hardsub.isNotBlank()) emptyList() else subsList
|
||||||
|
)
|
||||||
|
} catch (_: Error) {
|
||||||
|
Video(videoUrl, quality, videoUrl)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}.getOrNull()
|
||||||
}.getOrNull()
|
}
|
||||||
}
|
|
||||||
.filterNotNull()
|
.filterNotNull()
|
||||||
.flatten()
|
.flatten()
|
||||||
}
|
}
|
||||||
@ -250,25 +216,34 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
|||||||
return locale.firstOrNull { it.first == this }?.second ?: ""
|
return locale.firstOrNull { it.first == this }?.second ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun String.isNumeric(): Boolean {
|
||||||
|
return this@isNumeric.toDoubleOrNull() != null
|
||||||
|
}
|
||||||
|
|
||||||
private val locale = arrayOf(
|
private val locale = arrayOf(
|
||||||
Pair("ar-ME", "Arabic"),
|
Pair("ar-ME", "Arabic"),
|
||||||
Pair("ar-SA", "Arabic (Saudi Arabia)"),
|
Pair("ar-SA", "Arabic (Saudi Arabia)"),
|
||||||
Pair("de-DE", "German"),
|
Pair("de-DE", "German"),
|
||||||
Pair("en-US", "English"),
|
Pair("en-US", "English"),
|
||||||
Pair("es-419", "Spanish"),
|
Pair("en-IN", "English (India)"),
|
||||||
Pair("es-ES", "Spanish (Spain)"),
|
Pair("es-419", "Spanish (América Latina)"),
|
||||||
Pair("es-LA", "Spanish (Spanish)"),
|
Pair("es-ES", "Spanish (España)"),
|
||||||
|
Pair("es-LA", "Spanish (América Latina)"),
|
||||||
Pair("fr-FR", "French"),
|
Pair("fr-FR", "French"),
|
||||||
Pair("ja-JP", "Japanese"),
|
Pair("ja-JP", "Japanese"),
|
||||||
|
Pair("hi-IN", "Hindi"),
|
||||||
Pair("it-IT", "Italian"),
|
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("pl-PL", "Polish"),
|
||||||
Pair("ru-RU", "Russian"),
|
Pair("ru-RU", "Russian"),
|
||||||
Pair("tr-TR", "Turkish"),
|
Pair("tr-TR", "Turkish"),
|
||||||
Pair("uk-UK", "Ukrainian"),
|
Pair("uk-UK", "Ukrainian"),
|
||||||
Pair("he-IL", "Hebrew"),
|
Pair("he-IL", "Hebrew"),
|
||||||
Pair("ro-RO", "Romanian"),
|
Pair("ro-RO", "Romanian"),
|
||||||
Pair("sv-SE", "Swedish")
|
Pair("sv-SE", "Swedish"),
|
||||||
|
Pair("zh-CN", "Chinese")
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun LinkData.toJsonString(): String {
|
private fun LinkData.toJsonString(): String {
|
||||||
@ -284,6 +259,23 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
|||||||
.getOrNull() ?: 0L
|
.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> {
|
override fun List<Video>.sort(): List<Video> {
|
||||||
val quality = preferences.getString("preferred_quality", "1080")!!
|
val quality = preferences.getString("preferred_quality", "1080")!!
|
||||||
val dubLocale = preferences.getString("preferred_audio", "en-US")!!
|
val dubLocale = preferences.getString("preferred_audio", "en-US")!!
|
||||||
@ -302,22 +294,6 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun setupPreferenceScreen(screen: PreferenceScreen) {
|
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 {
|
val videoQualityPref = ListPreference(screen.context).apply {
|
||||||
key = "preferred_quality"
|
key = "preferred_quality"
|
||||||
title = "Preferred quality"
|
title = "Preferred quality"
|
||||||
@ -381,8 +357,6 @@ class Kamyroll : ConfigurableAnimeSource, AnimeHttpSource() {
|
|||||||
preferences.edit().putString(key, entry).commit()
|
preferences.edit().putString(key, entry).commit()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
screen.addPreference(domainPref)
|
|
||||||
screen.addPreference(videoQualityPref)
|
screen.addPreference(videoQualityPref)
|
||||||
screen.addPreference(audLocalePref)
|
screen.addPreference(audLocalePref)
|
||||||
screen.addPreference(subLocalePref)
|
screen.addPreference(subLocalePref)
|
@ -17,7 +17,7 @@ data class LinkData(
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Images(
|
data class Images(
|
||||||
val poster_tall: ArrayList<Image>? = null
|
val poster_tall: List<ArrayList<Image>>? = null
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Image(
|
data class Image(
|
||||||
@ -29,137 +29,107 @@ data class Images(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Metadata(
|
data class Anime(
|
||||||
val is_dubbed: Boolean,
|
val id: String,
|
||||||
val is_mature: Boolean,
|
val type: String? = null,
|
||||||
val is_subbed: Boolean,
|
val title: String,
|
||||||
val maturity_ratings: String,
|
val description: String,
|
||||||
val episode_count: Int? = null,
|
val images: Images,
|
||||||
val is_simulcast: Boolean? = null,
|
val series_metadata: Metadata? = null,
|
||||||
val season_count: Int? = null
|
val content_provider: String? = null,
|
||||||
)
|
val audio_locales: ArrayList<String>? = null,
|
||||||
|
val subtitle_locales: ArrayList<String>? = null
|
||||||
@Serializable
|
|
||||||
data class Updated(
|
|
||||||
val total: Int,
|
|
||||||
val items: ArrayList<Item>
|
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Item(
|
data class Metadata(
|
||||||
val id: String,
|
val maturity_ratings: ArrayList<String>,
|
||||||
val series_id: String,
|
val is_simulcast: Boolean,
|
||||||
val series_title: String,
|
val audio_locales: ArrayList<String>,
|
||||||
val description: String,
|
val subtitle_locales: ArrayList<String>,
|
||||||
val images: Images
|
@SerialName("tenant_categories")
|
||||||
|
val genres: ArrayList<String>?
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SearchResult(
|
data class AnimeResult(
|
||||||
val total: Int,
|
val total: Int,
|
||||||
val items: ArrayList<SearchItem>
|
val data: ArrayList<Anime>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SearchAnimeResult(
|
||||||
|
val total: Int,
|
||||||
|
val data: ArrayList<Result>
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class SearchItem(
|
data class Result(
|
||||||
val type: String,
|
val type: String,
|
||||||
val total: Int,
|
val count: Int,
|
||||||
val items: ArrayList<Item>
|
val items: ArrayList<Anime>
|
||||||
) {
|
)
|
||||||
@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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EpisodeList(
|
data class SeasonResult(
|
||||||
val total: Int,
|
val total: Int,
|
||||||
val items: ArrayList<Item>
|
val data: ArrayList<Season>
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Item(
|
data class Season(
|
||||||
@SerialName("__class__")
|
|
||||||
val media_class: String,
|
|
||||||
val id: String,
|
val id: String,
|
||||||
val type: String? = null,
|
val season_number: Int
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaResult(
|
data class EpisodeResult(
|
||||||
val id: String,
|
val total: Int,
|
||||||
val title: String,
|
val data: ArrayList<Episode>
|
||||||
val description: String,
|
) {
|
||||||
val images: Images,
|
@Serializable
|
||||||
val maturity_ratings: String,
|
data class Episode(
|
||||||
val content_provider: String,
|
val title: String,
|
||||||
val is_mature: Boolean,
|
@SerialName("sequence_number")
|
||||||
val is_subbed: Boolean,
|
val episode_number: Float,
|
||||||
val is_dubbed: Boolean,
|
val episode: String,
|
||||||
val episode_count: Int? = null,
|
@SerialName("episode_air_date")
|
||||||
val season_count: Int? = null,
|
val airDate: String,
|
||||||
val media_count: Int? = null,
|
val versions: ArrayList<Version>
|
||||||
val is_simulcast: Boolean? = null
|
) {
|
||||||
)
|
@Serializable
|
||||||
|
data class Version(
|
||||||
@Serializable
|
val audio_locale: String,
|
||||||
data class RawEpisode(
|
@SerialName("guid")
|
||||||
val id: String,
|
val id: String
|
||||||
val title: String,
|
)
|
||||||
val season: Int,
|
}
|
||||||
val episode: Float,
|
}
|
||||||
val air_date: String
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class EpisodeData(
|
data class EpisodeData(
|
||||||
val ids: List<String>
|
val ids: List<Pair<String, String>>
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class VideoStreams(
|
data class VideoStreams(
|
||||||
val streams: List<Stream>,
|
val sources: List<Stream>,
|
||||||
val subtitles: List<Subtitle>
|
val subtitles: List<Subtitle>
|
||||||
) {
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Stream(
|
data class Stream(
|
||||||
@SerialName("audio_locale")
|
val url: String,
|
||||||
val audio: String,
|
val quality: String
|
||||||
@SerialName("hardsub_locale")
|
|
||||||
val hardsub: String,
|
|
||||||
val url: String
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Subtitle(
|
data class Subtitle(
|
||||||
val locale: String,
|
val url: String,
|
||||||
val url: String
|
val lang: String
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun <T> List<T>.thirdLast(): T {
|
fun <T> List<T>.thirdLast(): T? {
|
||||||
if (size < 3) throw NoSuchElementException("List has less than three elements")
|
if (size < 3) return null
|
||||||
return this[size - 3]
|
return this[size - 3]
|
||||||
}
|
}
|
||||||
|