Initial commit

This commit is contained in:
almightyhak 2024-06-20 11:54:12 +07:00
commit 98ed7e8839
2263 changed files with 108711 additions and 0 deletions

View file

@ -0,0 +1,10 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 3
dependencies {
api(project(":lib:megacloud-extractor"))
api(project(":lib:streamtape-extractor"))
}

View file

@ -0,0 +1,444 @@
package eu.kanade.tachiyomi.multisrc.zorotheme
import android.app.Application
import android.content.SharedPreferences
import android.widget.Toast
import androidx.preference.ListPreference
import androidx.preference.MultiSelectListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
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.multisrc.zorotheme.dto.HtmlResponse
import eu.kanade.tachiyomi.multisrc.zorotheme.dto.SourcesResponse
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.util.parallelCatchingFlatMap
import eu.kanade.tachiyomi.util.parallelMapNotNull
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
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 uy.kohesive.injekt.injectLazy
abstract class ZoroTheme(
override val lang: String,
override val name: String,
override val baseUrl: String,
private val hosterNames: List<String>,
) : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val supportsLatest = true
private val json: Json by injectLazy()
val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
.clearOldHosts()
}
private val docHeaders = headers.newBuilder().apply {
add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8")
add("Host", baseUrl.toHttpUrl().host)
add("Referer", "$baseUrl/")
}.build()
protected open val ajaxRoute = ""
private val useEnglish by lazy { preferences.getTitleLang == "English" }
private val markFiller by lazy { preferences.markFiller }
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int): Request = GET("$baseUrl/most-popular?page=$page", docHeaders)
override fun popularAnimeSelector(): String = "div.flw-item"
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
element.selectFirst("div.film-detail a")!!.let {
setUrlWithoutDomain(it.attr("href"))
title = if (useEnglish && it.hasAttr("title")) {
it.attr("title")
} else {
it.attr("data-jname")
}
}
thumbnail_url = element.selectFirst("div.film-poster > img")!!.attr("data-src")
}
override fun popularAnimeNextPageSelector() = "li.page-item a[title=Next]"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/top-airing?page=$page", docHeaders)
override fun latestUpdatesSelector(): String = popularAnimeSelector()
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector(): String = popularAnimeNextPageSelector()
// =============================== Search ===============================
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val params = ZoroThemeFilters.getSearchParameters(filters)
val endpoint = if (query.isEmpty()) "filter" else "search"
val url = "$baseUrl/$endpoint".toHttpUrl().newBuilder().apply {
addQueryParameter("page", page.toString())
addIfNotBlank("keyword", query)
addIfNotBlank("type", params.type)
addIfNotBlank("status", params.status)
addIfNotBlank("rated", params.rated)
addIfNotBlank("score", params.score)
addIfNotBlank("season", params.season)
addIfNotBlank("language", params.language)
addIfNotBlank("sort", params.sort)
addIfNotBlank("sy", params.start_year)
addIfNotBlank("sm", params.start_month)
addIfNotBlank("sd", params.start_day)
addIfNotBlank("ey", params.end_year)
addIfNotBlank("em", params.end_month)
addIfNotBlank("ed", params.end_day)
addIfNotBlank("genres", params.genres)
}.build()
return GET(url, docHeaders)
}
override fun searchAnimeSelector() = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()
// ============================== Filters ===============================
override fun getFilterList() = ZoroThemeFilters.FILTER_LIST
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
thumbnail_url = document.selectFirst("div.anisc-poster img")!!.attr("src")
document.selectFirst("div.anisc-info")!!.let { info ->
author = info.getInfo("Studios:")
status = parseStatus(info.getInfo("Status:"))
genre = info.getInfo("Genres:", isList = true)
description = buildString {
info.getInfo("Overview:")?.also { append(it + "\n") }
info.getInfo("Aired:", full = true)?.also(::append)
info.getInfo("Premiered:", full = true)?.also(::append)
info.getInfo("Synonyms:", full = true)?.also(::append)
info.getInfo("Japanese:", full = true)?.also(::append)
}
}
}
private fun Element.getInfo(
tag: String,
isList: Boolean = false,
full: Boolean = false,
): String? {
if (isList) {
return select("div.item-list:contains($tag) > a").eachText().joinToString()
}
val value = selectFirst("div.item-title:contains($tag)")
?.selectFirst("*.name, *.text")
?.text()
return if (full && value != null) "\n$tag $value" else value
}
private fun parseStatus(statusString: String?): Int {
return when (statusString) {
"Currently Airing" -> SAnime.ONGOING
"Finished Airing" -> SAnime.COMPLETED
else -> SAnime.UNKNOWN
}
}
// ============================== Episodes ==============================
override fun episodeListRequest(anime: SAnime): Request {
val id = anime.url.substringAfterLast("-")
return GET("$baseUrl/ajax$ajaxRoute/episode/list/$id", apiHeaders(baseUrl + anime.url))
}
override fun episodeListSelector() = "a.ep-item"
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.parseAs<HtmlResponse>().getHtml()
return document.select(episodeListSelector())
.map(::episodeFromElement)
.reversed()
}
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
episode_number = element.attr("data-number").toFloatOrNull() ?: 1F
name = "Ep. ${element.attr("data-number")}: ${element.attr("title")}"
setUrlWithoutDomain(element.attr("href"))
if (element.hasClass("ssl-item-filler") && markFiller) {
scanlator = "Filler Episode"
}
}
// ============================ Video Links =============================
override fun videoListRequest(episode: SEpisode): Request {
val id = episode.url.substringAfterLast("?ep=")
return GET("$baseUrl/ajax$ajaxRoute/episode/servers?episodeId=$id", apiHeaders(baseUrl + episode.url))
}
data class VideoData(
val type: String,
val link: String,
val name: String,
)
override suspend fun getVideoList(episode: SEpisode): List<Video> {
val response = client.newCall(videoListRequest(episode)).await()
val episodeReferer = response.request.header("referer")!!
val typeSelection = preferences.typeToggle
val hosterSelection = preferences.hostToggle
val serversDoc = response.parseAs<HtmlResponse>().getHtml()
val embedLinks = listOf("servers-sub", "servers-dub", "servers-mixed").map { type ->
if (type !in typeSelection) return@map emptyList()
serversDoc.select("div.$type div.item").parallelMapNotNull {
val id = it.attr("data-id")
val type = it.attr("data-type")
val name = it.text()
if (hosterSelection.contains(name, true).not()) return@parallelMapNotNull null
val link = client.newCall(
GET("$baseUrl/ajax$ajaxRoute/episode/sources?id=$id", apiHeaders(episodeReferer)),
).await().parseAs<SourcesResponse>().link ?: ""
VideoData(type, link, name)
}
}.flatten()
return embedLinks.parallelCatchingFlatMap(::extractVideo)
}
abstract fun extractVideo(server: VideoData): List<Video>
override fun videoListSelector() = throw UnsupportedOperationException()
override fun videoFromElement(element: Element) = throw UnsupportedOperationException()
override fun videoUrlParse(document: Document) = throw UnsupportedOperationException()
// ============================= Utilities ==============================
private fun SharedPreferences.clearOldHosts(): SharedPreferences {
if (hostToggle.all { hosterNames.contains(it) }) {
return this
}
edit()
.remove(PREF_HOSTER_KEY)
.putStringSet(PREF_HOSTER_KEY, hosterNames.toSet())
.remove(PREF_SERVER_KEY)
.putString(PREF_SERVER_KEY, hosterNames.first())
.apply()
return this
}
private fun Set<String>.contains(s: String, ignoreCase: Boolean): Boolean {
return any { it.equals(s, ignoreCase) }
}
private fun apiHeaders(referer: String): Headers = headers.newBuilder().apply {
add("Accept", "*/*")
add("Host", baseUrl.toHttpUrl().host)
add("Referer", referer)
add("X-Requested-With", "XMLHttpRequest")
}.build()
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
if (value.isNotBlank()) {
addQueryParameter(query, value)
}
return this
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.prefQuality
val lang = preferences.prefLang
val server = preferences.prefServer
return this.sortedWith(
compareByDescending<Video> { it.quality.contains(quality) }
.thenByDescending { it.quality.contains(server, true) }
.thenByDescending { it.quality.contains(lang, true) },
)
}
private val SharedPreferences.getTitleLang
get() = getString(PREF_TITLE_LANG_KEY, PREF_TITLE_LANG_DEFAULT)!!
private val SharedPreferences.markFiller
get() = getBoolean(MARK_FILLERS_KEY, MARK_FILLERS_DEFAULT)
private val SharedPreferences.prefQuality
get() = getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
private val SharedPreferences.prefServer
get() = getString(PREF_SERVER_KEY, hosterNames.first())!!
private val SharedPreferences.prefLang
get() = getString(PREF_LANG_KEY, PREF_LANG_DEFAULT)!!
private val SharedPreferences.hostToggle
get() = getStringSet(PREF_HOSTER_KEY, hosterNames.toSet())!!
private val SharedPreferences.typeToggle
get() = getStringSet(PREF_TYPE_TOGGLE_KEY, PREF_TYPES_TOGGLE_DEFAULT)!!
companion object {
private const val PREF_TITLE_LANG_KEY = "preferred_title_lang"
private const val PREF_TITLE_LANG_DEFAULT = "Romaji"
private val PREF_TITLE_LANG_LIST = arrayOf("Romaji", "English")
private const val MARK_FILLERS_KEY = "mark_fillers"
private const val MARK_FILLERS_DEFAULT = true
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_DEFAULT = "1080"
private const val PREF_LANG_KEY = "preferred_language"
private const val PREF_LANG_DEFAULT = "Sub"
private const val PREF_SERVER_KEY = "preferred_server"
private const val PREF_HOSTER_KEY = "hoster_selection"
private const val PREF_TYPE_TOGGLE_KEY = "type_selection"
private val TYPES_ENTRIES = arrayOf("Sub", "Dub", "Mixed")
private val TYPES_ENTRY_VALUES = arrayOf("servers-sub", "servers-dub", "servers-mixed")
private val PREF_TYPES_TOGGLE_DEFAULT = TYPES_ENTRY_VALUES.toSet()
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_TITLE_LANG_KEY
title = "Preferred title language"
entries = PREF_TITLE_LANG_LIST
entryValues = PREF_TITLE_LANG_LIST
setDefaultValue(PREF_TITLE_LANG_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = MARK_FILLERS_KEY
title = "Mark filler episodes"
setDefaultValue(MARK_FILLERS_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
Toast.makeText(screen.context, "Restart Aniyomi to apply new setting.", Toast.LENGTH_LONG).show()
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
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 = hosterNames.toTypedArray()
entryValues = hosterNames.toTypedArray()
setDefaultValue(hosterNames.first())
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_LANG_KEY
title = "Preferred Type"
entries = TYPES_ENTRIES
entryValues = TYPES_ENTRIES
setDefaultValue(PREF_LANG_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)
MultiSelectListPreference(screen.context).apply {
key = PREF_HOSTER_KEY
title = "Enable/Disable Hosts"
entries = hosterNames.toTypedArray()
entryValues = hosterNames.toTypedArray()
setDefaultValue(hosterNames.toSet())
setOnPreferenceChangeListener { _, newValue ->
@Suppress("UNCHECKED_CAST")
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
MultiSelectListPreference(screen.context).apply {
key = PREF_TYPE_TOGGLE_KEY
title = "Enable/Disable Types"
entries = TYPES_ENTRIES
entryValues = TYPES_ENTRY_VALUES
setDefaultValue(PREF_TYPES_TOGGLE_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
@Suppress("UNCHECKED_CAST")
preferences.edit().putStringSet(key, newValue as Set<String>).commit()
}
}.also(screen::addPreference)
}
}

View file

@ -0,0 +1,244 @@
package eu.kanade.tachiyomi.multisrc.zorotheme
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object ZoroThemeFilters {
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, values: List<CheckBox>) : AnimeFilter.Group<AnimeFilter.CheckBox>(name, values)
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return this.filterIsInstance<R>().joinToString("") {
(it as QueryPartFilter).toQueryPart()
}
}
class TypeFilter : QueryPartFilter("Type", ZoroThemeFiltersData.TYPES)
class StatusFilter : QueryPartFilter("Status", ZoroThemeFiltersData.STATUS)
class RatedFilter : QueryPartFilter("Rated", ZoroThemeFiltersData.RATED)
class ScoreFilter : QueryPartFilter("Score", ZoroThemeFiltersData.SCORES)
class SeasonFilter : QueryPartFilter("Season", ZoroThemeFiltersData.SEASONS)
class LanguageFilter : QueryPartFilter("Language", ZoroThemeFiltersData.LANGUAGES)
class SortFilter : QueryPartFilter("Sort by", ZoroThemeFiltersData.SORTS)
class StartYearFilter : QueryPartFilter("Start year", ZoroThemeFiltersData.YEARS)
class StartMonthFilter : QueryPartFilter("Start month", ZoroThemeFiltersData.MONTHS)
class StartDayFilter : QueryPartFilter("Start day", ZoroThemeFiltersData.DAYS)
class EndYearFilter : QueryPartFilter("End year", ZoroThemeFiltersData.YEARS)
class EndMonthFilter : QueryPartFilter("End month", ZoroThemeFiltersData.MONTHS)
class EndDayFilter : QueryPartFilter("End day", ZoroThemeFiltersData.DAYS)
class GenresFilter : CheckBoxFilterList(
"Genres",
ZoroThemeFiltersData.GENRES.map { CheckBoxVal(it.first, false) },
)
val FILTER_LIST get() = AnimeFilterList(
TypeFilter(),
StatusFilter(),
RatedFilter(),
ScoreFilter(),
SeasonFilter(),
LanguageFilter(),
SortFilter(),
AnimeFilter.Separator(),
StartYearFilter(),
StartMonthFilter(),
StartDayFilter(),
EndYearFilter(),
EndMonthFilter(),
EndDayFilter(),
AnimeFilter.Separator(),
GenresFilter(),
)
data class FilterSearchParams(
val type: String = "",
val status: String = "",
val rated: String = "",
val score: String = "",
val season: String = "",
val language: String = "",
val sort: String = "",
val start_year: String = "",
val start_month: String = "",
val start_day: String = "",
val end_year: String = "",
val end_month: String = "",
val end_day: String = "",
val genres: String = "",
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val genres: String = filters.filterIsInstance<GenresFilter>()
.first()
.state.mapNotNull { format ->
if (format.state) {
ZoroThemeFiltersData.GENRES.find { it.first == format.name }!!.second
} else { null }
}.joinToString(",")
return FilterSearchParams(
filters.asQueryPart<TypeFilter>(),
filters.asQueryPart<StatusFilter>(),
filters.asQueryPart<RatedFilter>(),
filters.asQueryPart<ScoreFilter>(),
filters.asQueryPart<SeasonFilter>(),
filters.asQueryPart<LanguageFilter>(),
filters.asQueryPart<SortFilter>(),
filters.asQueryPart<StartYearFilter>(),
filters.asQueryPart<StartMonthFilter>(),
filters.asQueryPart<StartDayFilter>(),
filters.asQueryPart<EndYearFilter>(),
filters.asQueryPart<EndMonthFilter>(),
filters.asQueryPart<EndDayFilter>(),
genres,
)
}
private object ZoroThemeFiltersData {
val ALL = Pair("All", "")
val TYPES = arrayOf(
ALL,
Pair("Movie", "1"),
Pair("TV", "2"),
Pair("OVA", "3"),
Pair("ONA", "4"),
Pair("Special", "5"),
Pair("Music", "6"),
)
val STATUS = arrayOf(
ALL,
Pair("Finished Airing", "1"),
Pair("Currently Airing", "2"),
Pair("Not yet aired", "3"),
)
val RATED = arrayOf(
ALL,
Pair("G", "1"),
Pair("PG", "2"),
Pair("PG-13", "3"),
Pair("R", "4"),
Pair("R+", "5"),
Pair("Rx", "6"),
)
val SCORES = arrayOf(
ALL,
Pair("(1) Appalling", "1"),
Pair("(2) Horrible", "2"),
Pair("(3) Very Bad", "3"),
Pair("(4) Bad", "4"),
Pair("(5) Average", "5"),
Pair("(6) Fine", "6"),
Pair("(7) Good", "7"),
Pair("(8) Very Good", "8"),
Pair("(9) Great", "9"),
Pair("(10) Masterpiece", "10"),
)
val SEASONS = arrayOf(
ALL,
Pair("Spring", "1"),
Pair("Summer", "2"),
Pair("Fall", "3"),
Pair("Winter", "4"),
)
val LANGUAGES = arrayOf(
ALL,
Pair("SUB", "1"),
Pair("DUB", "2"),
Pair("SUB & DUB", "3"),
)
val SORTS = arrayOf(
Pair("Default", "default"),
Pair("Recently Added", "recently_added"),
Pair("Recently Updated", "recently_updated"),
Pair("Score", "score"),
Pair("Name A-Z", "name_az"),
Pair("Released Date", "released_date"),
Pair("Most Watched", "most_watched"),
)
val YEARS = arrayOf(ALL) + (1917..2024).map {
Pair(it.toString(), it.toString())
}.reversed().toTypedArray()
val MONTHS = arrayOf(ALL) + (1..12).map {
Pair(it.toString(), it.toString())
}.toTypedArray()
val DAYS = arrayOf(ALL) + (1..31).map {
Pair(it.toString(), it.toString())
}.toTypedArray()
val GENRES = arrayOf(
Pair("Action", "1"),
Pair("Adventure", "2"),
Pair("Cars", "3"),
Pair("Comedy", "4"),
Pair("Dementia", "5"),
Pair("Demons", "6"),
Pair("Drama", "8"),
Pair("Ecchi", "9"),
Pair("Fantasy", "10"),
Pair("Game", "11"),
Pair("Harem", "35"),
Pair("Historical", "13"),
Pair("Horror", "14"),
Pair("Isekai", "44"),
Pair("Josei", "43"),
Pair("Kids", "15"),
Pair("Magic", "16"),
Pair("Martial Arts", "17"),
Pair("Mecha", "18"),
Pair("Military", "38"),
Pair("Music", "19"),
Pair("Mystery", "7"),
Pair("Parody", "20"),
Pair("Police", "39"),
Pair("Psychological", "40"),
Pair("Romance", "22"),
Pair("Samurai", "21"),
Pair("School", "23"),
Pair("Sci-Fi", "24"),
Pair("Seinen", "42"),
Pair("Shoujo", "25"),
Pair("Shoujo Ai", "26"),
Pair("Shounen", "27"),
Pair("Shounen Ai", "28"),
Pair("Slice of Life", "36"),
Pair("Space", "29"),
Pair("Sports", "30"),
Pair("Super Power", "31"),
Pair("Supernatural", "37"),
Pair("Thriller", "41"),
Pair("Vampire", "32"),
Pair("Yaoi", "33"),
Pair("Yuri", "34"),
)
}
}

View file

@ -0,0 +1,39 @@
package eu.kanade.tachiyomi.multisrc.zorotheme.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
@Serializable
data class HtmlResponse(
val html: String,
) {
fun getHtml(): Document {
return Jsoup.parseBodyFragment(html)
}
}
@Serializable
data class SourcesResponse(
val link: String? = null,
)
@Serializable
data class VideoDto(
val sources: List<VideoLink>,
val tracks: List<TrackDto>? = null,
)
@Serializable
data class SourceResponseDto(
val sources: JsonElement,
val encrypted: Boolean = true,
val tracks: List<TrackDto>? = null,
)
@Serializable
data class VideoLink(val file: String = "")
@Serializable
data class TrackDto(val file: String, val kind: String, val label: String = "")