feat(hi/animesaga): New extension (#1892)

This commit is contained in:
Secozzi 2023-07-12 10:33:28 +02:00 committed by GitHub
parent 33a00d63e3
commit 2a8ca3c52e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 562 additions and 24 deletions

View File

@ -680,13 +680,12 @@ Public License instead of this License. But first, please read
package eu.kanade.tachiyomi.lib.chillxextractor
import android.util.Base64
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
@ -698,7 +697,7 @@ import javax.crypto.spec.SecretKeySpec
class ChillxExtractor(private val client: OkHttpClient, private val headers: Headers) {
fun videoFromUrl(url: String, referer: String): List<Video>? {
fun videoFromUrl(url: String, referer: String, prefix: String = "Chillx - "): List<Video> {
val videoList = mutableListOf<Video>()
val mainUrl = "https://${url.toHttpUrl().host}"
@ -707,16 +706,12 @@ class ChillxExtractor(private val client: OkHttpClient, private val headers: Hea
).execute().asJsoup().html()
val master = Regex("""MasterJS\s*=\s*'([^']+)""").find(document)?.groupValues?.get(1)
val aesJson = Json.decodeFromString<CryptoInfo>(base64Decode(master ?: return null).toString(Charsets.UTF_8))
val aesJson = Json.decodeFromString<CryptoInfo>(base64Decode(master ?: return emptyList()).toString(Charsets.UTF_8))
val decrypt = cryptoAESHandler(aesJson, KEY)
val decrypt = cryptoAESHandler(aesJson ?: return null, KEY)
val playlistUrl = Regex("""sources:\s*\[\{"file":"([^"]+)""").find(decrypt)?.groupValues?.get(1) ?: return null
val tracks = Regex("""tracks:\s*\[(.+)]""").find(decrypt)?.groupValues?.get(1)
// TODO: Add subtitle support when a site is found that uses it
val trackJson = Json.decodeFromString<SubtitleTrack>(tracks ?: return null)
val masterUrl = Regex("""sources:\s*\[\{"file":"([^"]+)""").find(decrypt)?.groupValues?.get(1)
?: Regex("""file: ?"([^"]+)"""").find(decrypt)?.groupValues?.get(1)
?: return emptyList()
val masterHeaders = Headers.headersOf(
"Accept", "*/*",
@ -728,23 +723,57 @@ class ChillxExtractor(private val client: OkHttpClient, private val headers: Hea
"Referer", "$mainUrl/",
)
val response = client.newCall(GET(playlistUrl, headers = masterHeaders)).execute()
val response = client.newCall(GET(masterUrl, headers = masterHeaders)).execute()
val masterPlaylist = response.body.string()
val masterBase = "https://${masterUrl.toHttpUrl().host}${masterUrl.toHttpUrl().encodedPath}"
.substringBeforeLast("/") + "/"
val audioRegex = Regex("""#EXT-X-MEDIA:TYPE=AUDIO.*?NAME="(.*?)".*?URI="(.*?)"""")
val audioList: List<Track> = audioRegex.findAll(masterPlaylist)
.map {
var audioUrl = it.groupValues[2]
if (audioUrl.startsWith("https").not()) {
audioUrl = masterBase + audioUrl
}
Track(
audioUrl, // Url
it.groupValues[1], // Name
)
}.toList()
val subtitleList = mutableListOf<Track>()
if (decrypt.contains("subtitle: ")) {
val subtitleStr = decrypt.substringAfter("subtitle: ").substringBefore("\n")
val subtitleRegex = Regex("""\[(.*?)\](.*?)"?\,""")
subtitleRegex.findAll(subtitleStr).forEach {
subtitleList.add(
Track(
it.groupValues[2],
it.groupValues[1],
)
)
}
}
masterPlaylist.substringAfter("#EXT-X-STREAM-INF:")
.split("#EXT-X-STREAM-INF:").map {
.split("#EXT-X-STREAM-INF:").map {
val quality = it.substringAfter("RESOLUTION=").split(",")[0].split("\n")[0].substringAfter("x") + "p"
var videoUrl = it.substringAfter("\n").substringBefore("\n")
if (videoUrl.startsWith("https").not()) {
val plUrl = playlistUrl.toHttpUrl()
videoUrl = "https://${plUrl.host}${plUrl.encodedPath.substringBeforeLast("/")}/$videoUrl"
videoUrl = masterBase + videoUrl
}
val videoHeaders = headers.newBuilder()
.addAll(masterHeaders)
.build()
videoList.add(Video(videoUrl, quality, videoUrl, headers = videoHeaders))
if (audioList.isEmpty()) {
videoList.add(Video(videoUrl, prefix + quality, videoUrl, headers = videoHeaders, subtitleTracks = subtitleList))
} else {
videoList.add(Video(videoUrl, prefix + quality, videoUrl, headers = videoHeaders, audioTracks = audioList, subtitleTracks = subtitleList))
}
}
return videoList
}
@ -790,13 +819,6 @@ class ChillxExtractor(private val client: OkHttpClient, private val headers: Hea
.toByteArray()
}
@Serializable
data class SubtitleTrack(
val file: String? = null,
val label: String? = null,
val kind: String? = null,
)
companion object {
private const val KEY = "11x&W5UBrcqn\$9Yl"
}

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest />

View File

@ -0,0 +1,17 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'AnimeSAGA'
pkgNameSuffix = 'hi.animesaga'
extClass = '.AnimeSAGA'
extVersionCode = 1
libVersion = '13'
}
dependencies {
implementation(project(':lib-streamsb-extractor'))
implementation(project(':lib-chillx-extractor'))
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -0,0 +1,358 @@
package eu.kanade.tachiyomi.animeextension.hi.animesaga
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.Video
import eu.kanade.tachiyomi.animesource.online.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.lib.chillxextractor.ChillxExtractor
import eu.kanade.tachiyomi.lib.streamsbextractor.StreamSBExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import okhttp3.FormBody
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.Locale
import kotlin.math.ceil
import kotlin.math.floor
class AnimeSAGA : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "AnimeSAGA"
override val baseUrl = "https://www.animesaga.in"
private val videoHost = "cdn.animesaga.in"
override val lang = "hi"
override val supportsLatest = true
override val client: OkHttpClient = network.cloudflareClient
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/series?sorting=popular".addPage(page), headers)
override fun popularAnimeSelector(): String = "div#content > div#content.row > div"
override fun popularAnimeFromElement(element: Element): SAnime = SAnime.create().apply {
setUrlWithoutDomain(element.selectFirst("a[href]")!!.attr("href"))
thumbnail_url = element.selectFirst("picture")?.getImage() ?: ""
title = element.selectFirst(".title")!!.text()
}
override fun popularAnimeNextPageSelector(): String = "ul.pagination > li.active + li:has(a)"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request =
GET("$baseUrl/series?sorting=newest&released=1960;2023".addPage(page), headers)
override fun latestUpdatesSelector(): String = popularAnimeSelector()
override fun latestUpdatesFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector(): String = popularAnimeNextPageSelector()
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
return if (query.isNotBlank()) {
require(query.length > 2) { "Search query must be longer than three letters" }
GET("$baseUrl/search/${query.replace("+", "")}".addPage(page), headers)
} else {
val filters = AnimeSAGAFilters.getSearchParameters(filters)
// Validation for ratings
if (filters.ratingStart.isNotEmpty()) {
require(filters.ratingEnd.isNotEmpty()) { "Both start and end must either be empty or populated" }
}
if (filters.ratingEnd.isNotEmpty()) {
require(filters.ratingStart.isNotEmpty()) { "Both start and end must either be empty or populated" }
}
if (filters.ratingStart.isNotEmpty()) {
require(filters.ratingStart.toFloatOrNull() != null) { "${filters.ratingStart} is not a float." }
require(filters.ratingEnd.toFloatOrNull() != null) { "${filters.ratingEnd} is not a float." }
require(filters.ratingStart.toFloat() in 5.0..10.0) { "Start must be between 5.0 and 10.0" }
require(filters.ratingEnd.toFloat() in 5.0..10.0) { "End must be between 5.0 and 10.0" }
}
// Create url
val url = baseUrl.toHttpUrl().newBuilder().apply {
addPathSegment(filters.type)
if (filters.sorting.isNotEmpty()) addEncodedQueryParameter("sorting", filters.sorting)
if (filters.genre.isNotEmpty()) addEncodedQueryParameter("genre", filters.genre)
if (filters.ratingStart.isNotEmpty()) addEncodedQueryParameter("imdb", "${filters.ratingStart.toFloat().stringify()};${filters.ratingEnd.toFloat().stringify()}")
addEncodedQueryParameter("released", "${filters.yearStart};${filters.yearEnd}")
}.toString().addPage(page)
GET(url, headers)
}
}
override fun searchAnimeParse(response: Response): AnimesPage {
return if (response.request.url.pathSegments.first() == "search") {
val document = response.asJsoup()
val animeList = document
.select("div.layout-section > div.row > div")
.map(::popularAnimeFromElement)
val hasNextPage = document.selectFirst(popularAnimeSelector()) != null
AnimesPage(animeList, hasNextPage)
} else {
super.searchAnimeParse(response)
}
}
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element): SAnime = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
// ============================== Filters ===============================
override fun getFilterList(): AnimeFilterList = AnimeSAGAFilters.FILTER_LIST
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime = SAnime.create().apply {
description = document.select("div.col-md > p.fs-sm").text()
genre = document.select("div.card-tag > a").joinToString(", ") { it.text() }
}
// ============================== Episodes ==============================
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val episodeList = mutableListOf<SEpisode>()
val seasonList = document.select("div#seasonAccordion > div.accordion-item")
// For movies
if (seasonList.size == 0) {
val dateStr: String? = document.select("ul.list-inline > li:not(:has(a))")
.firstOrNull { t ->
Regex("^[A-Za-z]{3}\\. \\d{2}, \\d{4}$").matches(t.text().trim())
}?.text()
episodeList.add(
SEpisode.create().apply {
setUrlWithoutDomain(response.request.url.toString())
name = "Movie"
episode_number = 1F
date_upload = dateStr?.let { parseDate(it) } ?: 0L
},
)
} else {
seasonList.forEach { season ->
val seasonText = season.selectFirst("div.accordion-header")!!.text().trim()
season.select(episodeListSelector()).forEachIndexed { index, ep ->
val epNumber = ep.selectFirst("a.episode")!!.text().trim().substringAfter("pisode ")
episodeList.add(
SEpisode.create().apply {
setUrlWithoutDomain(ep.selectFirst("a[href]")!!.attr("abs:href"))
name = "$seasonText Ep. $epNumber ${ep.selectFirst("a.name")?.text()?.trim() ?: ""}"
episode_number = epNumber.toFloatOrNull() ?: (index + 1).toFloat()
},
)
}
}
}
return episodeList.reversed()
}
override fun episodeListSelector() = "div.episodes > div.card-episode"
override fun episodeFromElement(element: Element): SEpisode = throw Exception("Not used")
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val document = response.asJsoup()
val videoList = document.select("div.card-stream > button[data-id]").mapNotNull { stream ->
val postBody = FormBody.Builder()
.add("id", stream.attr("data-id"))
.build()
val postHeaders = headers.newBuilder()
.add("Accept", "*/*")
.add("Content-Length", postBody.contentLength().toString())
.add("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.add("Host", baseUrl.toHttpUrl().host)
.add("Origin", baseUrl)
.add("Referer", response.request.url.toString())
.add("X-Requested-With", "XMLHttpRequest")
.build()
val doc = client.newCall(
POST("$baseUrl/ajax/embed", body = postBody, headers = postHeaders),
).execute().asJsoup()
doc.selectFirst("iframe[data-src]")?.attr("abs:data-src")
}.parallelMap { iframeUrl ->
runCatching {
extractVideosFromIframe(iframeUrl)
}.getOrElse { emptyList() }
}.flatten()
require(videoList.isNotEmpty()) { "Failed to fetch videos" }
return videoList.sort()
}
private fun extractVideosFromIframe(iframeUrl: String): List<Video> {
return when {
iframeUrl.toHttpUrl().host.equals(videoHost) -> {
ChillxExtractor(client, headers).videoFromUrl(iframeUrl, "$baseUrl/")
}
STREAMSB_DOMAINS.any { it in iframeUrl } -> {
StreamSBExtractor(client).videosFromUrl(iframeUrl, headers, prefix = "StreamSB - ")
}
else -> emptyList()
}
}
override fun videoListSelector() = throw Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
// ============================= Utilities ==============================
private fun String.addPage(page: Int): String {
return if (page == 1) {
this
} else {
this.toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.toString()
}
}
private fun Element.getImage(): String {
return this.selectFirst("source[data-srcset][type=image/png]")?.attr("abs:data-srcset")
?: this.selectFirst("img[data-src]")?.attr("abs:data-src")
?: ""
}
private fun Float.stringify(): String {
return when {
ceil(this) == floor(this) -> this.toInt().toString()
else -> "%.1f".format(this).replace(",", ".")
}
}
private fun parseDate(dateStr: String): Long {
return runCatching { DATE_FORMATTER.parse(dateStr)?.time }
.getOrNull() ?: 0L
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!
return this.sortedWith(
compareBy(
{ it.quality.contains(quality) },
{ Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
{ it.quality.contains(server, true) },
),
).reversed()
}
// From Dopebox
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("MMM. dd, yyyy", Locale.ENGLISH)
}
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_SERVER_DEFAULT = "chillx"
private val STREAMSB_DOMAINS = listOf(
"sbhight", "sbrity", "sbembed.com", "sbembed1.com", "sbplay.org",
"sbvideo.net", "streamsb.net", "sbplay.one", "cloudemb.com",
"playersb.com", "tubesb.com", "sbplay1.com", "embedsb.com",
"watchsb.com", "sbplay2.com", "japopav.tv", "viewsb.com",
"sbfast", "sbfull.com", "javplaya.com", "ssbstream.net",
"p1ayerjavseen.com", "sbthe.com", "vidmovie.xyz", "sbspeed.com",
"streamsss.net", "sblanh.com", "tvmshow.com", "sbanh.com",
"streamovies.xyz", "sblona.com", "baryonmode.online",
)
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = "Preferred quality"
entries = arrayOf("1080p", "720p", "480p", "360p")
entryValues = arrayOf("1080", "720", "480", "360")
setDefaultValue(PREF_QUALITY_DEFAULT)
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()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SERVER_KEY
title = "Preferred server"
entries = arrayOf("Chillx", "StreamSB")
entryValues = arrayOf("chillx", "streamsb")
setDefaultValue(PREF_SERVER_DEFAULT)
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()
}
}.also(screen::addPreference)
}
}

View File

@ -0,0 +1,139 @@
package eu.kanade.tachiyomi.animeextension.hi.animesaga
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object AnimeSAGAFilters {
open class QueryPartFilter(
displayName: String,
val vals: Array<Pair<String, String>>,
) : AnimeFilter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray(),
) {
fun toQueryPart() = vals[state].second
}
open class CheckBoxFilterList(name: String, pairs: Array<Pair<String, String>>) :
AnimeFilter.Group<AnimeFilter.CheckBox>(name, pairs.map { CheckBoxVal(it.first, false) })
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return (getFirst<R>() as QueryPartFilter).toQueryPart()
}
private inline fun <reified R> AnimeFilterList.getFirst(): R {
return first { it is R } as R
}
private inline fun <reified R> AnimeFilterList.parseCheckbox(
options: Array<Pair<String, String>>,
): String {
return (getFirst<R>() as CheckBoxFilterList).state
.filter { it.state }
.map { checkbox -> options.find { it.first == checkbox.name }!!.second }
.filter(String::isNotBlank)
.joinToString(",")
}
class TypeFilter : QueryPartFilter("Type", AnimeSAGAFiltersData.TYPE_LIST)
class GenreFilter : CheckBoxFilterList("Genres", AnimeSAGAFiltersData.GENRE_LIST)
class YearStart : QueryPartFilter("Release date start", AnimeSAGAFiltersData.YEAR_START)
class YearEnd : QueryPartFilter("Release date end", AnimeSAGAFiltersData.YEAR_END)
class SortFilter : QueryPartFilter("Sorting", AnimeSAGAFiltersData.SORTING_LIST)
class RatingStartFilter : AnimeFilter.Text("Rating lower bound")
class RatingEndFilter : AnimeFilter.Text("Rating upper bound")
val FILTER_LIST get() = AnimeFilterList(
AnimeFilter.Header("Text search ignores filters"),
TypeFilter(),
GenreFilter(),
YearStart(),
YearEnd(),
SortFilter(),
AnimeFilter.Separator(),
AnimeFilter.Header("Ratings must be a float between 5.0 and 10.0"),
RatingStartFilter(),
RatingEndFilter(),
)
data class FilterSearchParams(
val type: String = "",
val genre: String = "",
val yearStart: String = "",
val yearEnd: String = "",
val sorting: String = "",
val ratingStart: String = "",
val ratingEnd: String = "",
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
return FilterSearchParams(
filters.asQueryPart<TypeFilter>(),
filters.parseCheckbox<GenreFilter>(AnimeSAGAFiltersData.GENRE_LIST),
filters.asQueryPart<YearStart>(),
filters.asQueryPart<YearEnd>(),
filters.asQueryPart<SortFilter>(),
filters.filterIsInstance<RatingStartFilter>().first().state,
filters.filterIsInstance<RatingEndFilter>().first().state,
)
}
private object AnimeSAGAFiltersData {
val TYPE_LIST = arrayOf(
Pair("TV Shows", "series"),
Pair("Movies", "movies"),
)
// $("div.form-category > label.form-check").map((i,el) => `Pair("${$(el).find("span").first().text().trim()}", "${$(el).find("input").first().attr('value').trim()}")`).get().join(',\n')
// on /series
val GENRE_LIST = arrayOf(
Pair("Action", "1"),
Pair("Adventure", "2"),
Pair("Animation", "3"),
Pair("Comedy", "4"),
Pair("Crime", "5"),
Pair("Documentary", "6"),
Pair("Drama", "7"),
Pair("Family", "8"),
Pair("Fantasy", "9"),
Pair("History", "10"),
Pair("Horror", "11"),
Pair("Music", "12"),
Pair("Mystery", "13"),
Pair("Romance", "14"),
Pair("Science Fiction", "15"),
Pair("TV Movie", "16"),
Pair("Thriller", "17"),
Pair("War", "18"),
Pair("Western", "19"),
)
val YEAR_START = (2023 downTo 1960).reversed().map {
Pair(it.toString(), it.toString())
}.toTypedArray()
val YEAR_END = (2023 downTo 1960).map {
Pair(it.toString(), it.toString())
}.toTypedArray()
// $("div.form-category > label.form-check").map((i,el) => `Pair("${$(el).find("span").first().text().trim()}", "${$(el).find("input").first().attr('value').trim()}")`).get().join(',\n')
// on /series
val SORTING_LIST = arrayOf(
Pair("<select>", ""),
Pair("Newest", "newest"),
Pair("Most popular", "popular"),
Pair("Release date", "released"),
Pair("Imdb rating", "imdb"),
)
}
}