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
This commit is contained in:
Samfun75
2023-02-08 14:14:42 +03:00
committed by GitHub
parent 172ad279ca
commit 4ba680b983
16 changed files with 234 additions and 289 deletions

View File

@ -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'
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

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

View File

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

View File

@ -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]
}