Implement AniPlay source #115

Merged
uragiristereo merged 1 commit from src/en/aniplay into main 2024-08-07 07:57:33 -05:00
19 changed files with 506 additions and 0 deletions

View file

@ -0,0 +1,12 @@
ext {
extName = 'AniPlay'
extClass = '.AniPlay'
extVersionCode = 1
}
apply from: "$rootDir/common.gradle"
dependencies {
implementation(project(":lib-multisrc:anilist"))
implementation(project(":lib:playlist-utils"))
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="300"
android:viewportHeight="300">
<group android:scaleX="0.6"
android:scaleY="0.6"
android:translateX="60"
android:translateY="60">
<group>
<clip-path
android:pathData="M28.21,115.4h243.58v69.19h-243.58z"/>
<path
android:pathData="M45.85,116.85H58.59L76.21,167.96H64.07L60.6,156.51H43.77L40.31,167.96H28.21L45.85,116.85ZM58.14,148.4L52.21,128.73L46.21,148.4H58.14Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M115.7,167.96H104.21V147.15C104.21,144.71 103.74,142.93 102.8,141.84C102.35,141.29 101.79,140.86 101.14,140.57C100.5,140.29 99.8,140.15 99.1,140.18C97.97,140.19 96.86,140.48 95.86,141.01C94.73,141.57 93.71,142.35 92.86,143.29C91.99,144.27 91.31,145.39 90.86,146.62V167.96H79.27V130.6H89.63V136.98C90.58,135.51 91.79,134.24 93.21,133.21C94.73,132.14 96.42,131.34 98.21,130.84C100.2,130.28 102.26,130.01 104.33,130.02C106.39,129.93 108.44,130.38 110.27,131.34C111.72,132.14 112.92,133.33 113.73,134.77C114.52,136.19 115.06,137.74 115.31,139.35C115.57,140.93 115.7,142.53 115.71,144.13L115.7,167.96Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M119.33,125.85V115.4H130.85V125.85H119.33ZM119.33,167.96V130.16H130.85V167.96H119.33Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M158.67,168.68C156.08,168.75 153.53,168.12 151.26,166.88C149.18,165.72 147.45,164.01 146.26,161.95V183.3H134.77V130.6H144.77V136.76C146.1,134.63 147.98,132.88 150.2,131.7C152.43,130.51 154.92,129.94 157.44,130.02C159.85,130 162.24,130.51 164.44,131.51C166.56,132.5 168.46,133.89 170.02,135.63C171.62,137.4 172.87,139.48 173.69,141.73C174.57,144.13 175.01,146.68 174.99,149.24C175.04,152.67 174.33,156.06 172.9,159.18C171.63,161.99 169.63,164.4 167.1,166.18C164.62,167.86 161.67,168.74 158.67,168.68ZM154.79,158.96C155.98,158.98 157.15,158.7 158.21,158.16C159.24,157.63 160.15,156.9 160.91,156.02C161.69,155.11 162.29,154.05 162.67,152.91C163.08,151.71 163.29,150.45 163.28,149.18C163.29,147.91 163.06,146.65 162.6,145.48C162.15,144.37 161.51,143.35 160.69,142.48C159.88,141.62 158.9,140.93 157.81,140.48C156.67,140 155.45,139.76 154.21,139.77C153.42,139.78 152.63,139.89 151.87,140.12C151.09,140.35 150.33,140.68 149.64,141.12C148.95,141.55 148.3,142.08 147.73,142.66C147.14,143.27 146.66,143.97 146.29,144.74V152.4C146.8,153.63 147.51,154.75 148.38,155.74C149.23,156.7 150.24,157.49 151.38,158.07C152.43,158.64 153.6,158.95 154.79,158.96Z"
android:fillColor="#CDAAF5"/>
<path
android:pathData="M176.43,115.76H187.95V154.93C187.84,156.12 188.14,157.32 188.81,158.32C189.12,158.65 189.5,158.92 189.92,159.09C190.35,159.27 190.8,159.34 191.26,159.32C192.01,159.31 192.74,159.2 193.46,158.99C194.15,158.8 194.82,158.55 195.46,158.24L199.32,167.1C197.23,167.94 195.04,168.52 192.81,168.82C190.79,169.04 188.77,169.1 186.74,169.04C183.48,169.04 180.95,168.16 179.15,166.4C177.35,164.65 176.45,162.16 176.45,158.95L176.43,115.76Z"
android:fillColor="#CDAAF5"/>
<path
android:pathData="M198.59,156.84C198.55,154.52 199.29,152.26 200.68,150.4C202.18,148.47 204.17,146.96 206.44,146.04C209.1,144.92 211.97,144.36 214.86,144.4C216.38,144.4 217.9,144.53 219.4,144.79C220.74,145.02 222.04,145.4 223.29,145.91V144.29C223.34,143.43 223.2,142.57 222.88,141.77C222.55,140.98 222.05,140.26 221.42,139.68C220.17,138.61 218.28,138.08 215.73,138.09C213.7,138.07 211.68,138.44 209.79,139.16C207.73,139.99 205.76,141.03 203.92,142.26L200.46,134.91C202.88,133.32 205.51,132.09 208.28,131.24C211.07,130.42 213.97,130.01 216.88,130.02C222.54,130.02 226.94,131.36 230.09,134.02C233.24,136.69 234.81,140.54 234.81,145.57V155.24C234.73,156.06 234.92,156.89 235.35,157.6C235.6,157.85 235.9,158.05 236.22,158.19C236.55,158.33 236.9,158.4 237.26,158.4V167.99C236.26,168.18 235.4,168.32 234.56,168.41C233.83,168.51 233.1,168.56 232.36,168.57C230.62,168.68 228.89,168.22 227.43,167.26C226.3,166.4 225.54,165.14 225.3,163.74L225.09,162.09C223.5,164.17 221.44,165.86 219.09,167.01C216.82,168.13 214.31,168.71 211.78,168.71C209.44,168.74 207.13,168.21 205.05,167.15C203.13,166.17 201.49,164.7 200.33,162.88C199.17,161.08 198.57,158.98 198.59,156.84ZM221.49,158.18C221.99,157.8 222.43,157.33 222.78,156.79C223.09,156.37 223.27,155.87 223.29,155.35V152.09C222.25,151.69 221.18,151.4 220.08,151.21C219,151.01 217.91,150.91 216.81,150.9C214.96,150.83 213.13,151.3 211.55,152.26C210.92,152.61 210.39,153.13 210.02,153.76C209.65,154.38 209.46,155.1 209.46,155.82C209.46,156.65 209.71,157.45 210.18,158.12C210.69,158.83 211.38,159.41 212.18,159.79C213.12,160.22 214.15,160.43 215.18,160.4C216.36,160.4 217.53,160.19 218.64,159.79C219.68,159.42 220.64,158.88 221.49,158.18Z"
android:fillColor="#CDAAF5"/>
<path
android:pathData="M236.87,173.65C237.87,173.95 238.73,174.16 239.57,174.34C240.31,174.49 241.07,174.58 241.83,174.59C242.65,174.61 243.46,174.4 244.17,173.99C244.91,173.47 245.5,172.76 245.87,171.93C246.46,170.7 246.91,169.42 247.21,168.1L232.81,130.6H244.69L253.43,156.4L260.91,130.63H271.79L257.39,174.2C256.74,176.19 255.68,178.04 254.29,179.62C252.9,181.19 251.2,182.44 249.29,183.3C247.25,184.2 245.04,184.65 242.81,184.63C241.82,184.63 240.83,184.54 239.86,184.38C238.84,184.2 237.83,183.92 236.86,183.54L236.87,173.65Z"
android:fillColor="#CDAAF5"/>
</group>
</group>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 816 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#05010D</color>
</resources>

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,375 @@
package eu.kanade.tachiyomi.animeextension.en.aniplay
import android.app.Application
import android.util.Base64
import android.widget.Toast
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.SAnime
import eu.kanade.tachiyomi.animesource.model.SEpisode
import eu.kanade.tachiyomi.animesource.model.Track
import eu.kanade.tachiyomi.animesource.model.Video
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.multisrc.anilist.AniListAnimeHttpSource
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.parallelFlatMapBlocking
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.encodeToString
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.text.SimpleDateFormat
import java.util.Locale
class AniPlay : AniListAnimeHttpSource(), ConfigurableAnimeSource {
override val name = "AniPlay"
override val lang = "en"
override val baseUrl: String
get() = "https://${preferences.getString(PREF_DOMAIN_KEY, PREF_DOMAIN_DEFAULT)}"
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
/* ================================= AniList configurations ================================= */
override fun mapAnimeDetailUrl(animeId: Int): String {
return "$baseUrl/anime/info/$animeId"
}
override fun mapAnimeId(animeDetailUrl: String): Int {
val httpUrl = animeDetailUrl.toHttpUrl()
return httpUrl.pathSegments[2].toInt()
}
override fun getPreferredTitleLanguage(): TitleLanguage {
val preferredLanguage = preferences.getString(PREF_TITLE_LANGUAGE_KEY, PREF_TITLE_LANGUAGE_DEFAULT)
return when (preferredLanguage) {
"romaji" -> TitleLanguage.ROMAJI
"english" -> TitleLanguage.ENGLISH
"native" -> TitleLanguage.NATIVE
else -> TitleLanguage.ROMAJI
}
}
/* ====================================== Episode List ====================================== */
override fun episodeListRequest(anime: SAnime): Request {
val httpUrl = anime.url.toHttpUrl()
val animeId = httpUrl.pathSegments[2]
return GET("$baseUrl/api/anime/episode/$animeId")
}
override fun episodeListParse(response: Response): List<SEpisode> {
val isMarkFiller = preferences.getBoolean(PREF_MARK_FILLER_EPISODE_KEY, PREF_MARK_FILLER_EPISODE_DEFAULT)
val episodeListUrl = response.request.url
val animeId = episodeListUrl.pathSegments[3]
val providers = response.parseAs<List<EpisodeListResponse>>()
val episodes = mutableMapOf<Int, EpisodeListResponse.Episode>()
val episodeExtras = mutableMapOf<Int, List<EpisodeExtra>>()
providers.forEach { provider ->
provider.episodes.forEach { episode ->
if (!episodes.containsKey(episode.number)) {
episodes[episode.number] = episode
}
val existingEpisodeExtras = episodeExtras.getOrElse(episode.number) { emptyList() }
val episodeExtra = EpisodeExtra(
source = provider.providerId,
episodeId = episode.id,
hasDub = episode.hasDub,
)
episodeExtras[episode.number] = existingEpisodeExtras + listOf(episodeExtra)
}
}
return episodes.map { episodeMap ->
val episode = episodeMap.value
val episodeNumber = episode.number
val episodeExtra = episodeExtras.getValue(episodeNumber)
val episodeExtraString = json.encodeToString(episodeExtra)
.let { Base64.encode(it.toByteArray(), Base64.DEFAULT) }
.toString(Charsets.UTF_8)
val url = baseUrl.toHttpUrl().newBuilder()
.addPathSegment("anime")
.addPathSegment("watch")
.addQueryParameter("id", animeId)
.addQueryParameter("ep", episodeNumber.toString())
.addQueryParameter("extras", episodeExtraString)
.build()
val name = parseEpisodeName(episodeNumber, episode.title)
val uploadDate = parseDate(episode.createdAt)
val dub = when {
episodeExtra.any { it.hasDub } -> ", Dub"
else -> ""
}
val filler = when {
episode.isFiller && isMarkFiller -> " • Filler Episode"
else -> ""
}
val scanlator = "Sub$dub$filler"
SEpisode.create().apply {
this.url = url.toString()
this.name = name
this.date_upload = uploadDate
this.episode_number = episodeNumber.toFloat()
this.scanlator = scanlator
}
}.reversed()
}
/* ======================================= Video List ======================================= */
override suspend fun getVideoList(episode: SEpisode): List<Video> {
val episodeUrl = episode.url.toHttpUrl()
val animeId = episodeUrl.queryParameter("id") ?: return emptyList()
val episodeNum = episodeUrl.queryParameter("ep") ?: return emptyList()
val extras = episodeUrl.queryParameter("extras")
?.let {
Base64.decode(it, Base64.DEFAULT).toString(Charsets.UTF_8)
}
?.let { json.decodeFromString<List<EpisodeExtra>>(it) }
?: emptyList()
val episodeDataList = extras.parallelFlatMapBlocking { extra ->
val languages = mutableListOf("sub")
if (extra.hasDub) {
languages.add("dub")
}
val url = "$baseUrl/api/anime/source/$animeId"
languages.map { language ->
val requestBody = json
.encodeToString(
VideoSourceRequest(
source = extra.source,
episodeId = extra.episodeId,
episodeNum = episodeNum,
subType = language,
),
)
.toRequestBody("application/json".toMediaType())
val response = client
.newCall(POST(url = url, body = requestBody))
.execute()
.parseAs<VideoSourceResponse>()
EpisodeData(
source = extra.source,
language = language,
response = response,
)
}
}
val videos = episodeDataList.flatMap { episodeData ->
val defaultSource = episodeData.response.sources?.first {
it.quality in listOf("default", "auto")
} ?: return@flatMap emptyList()
val subtitles = episodeData.response.subtitles
?.filter { it.lang != "Thumbnails" }
?.map { Track(it.url, it.lang) }
?: emptyList()
playlistUtils.extractFromHls(
playlistUrl = defaultSource.url,
videoNameGen = { quality ->
val serverName = getServerName(episodeData.source)
val typeName = when {
subtitles.isNotEmpty() -> "SoftSub"
else -> getTypeName(episodeData.language)
}
"$serverName - $quality - $typeName"
},
subtitleList = subtitles,
)
}
return videos.sort()
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
val lang = preferences.getString(PREF_TYPE_KEY, PREF_TYPE_DEFAULT)!!.let(::getTypeName)
val server = preferences.getString(PREF_SERVER_KEY, PREF_SERVER_DEFAULT)!!.let(::getServerName)
return sortedWith(
compareByDescending<Video> { it.quality.contains(lang) }
.thenByDescending { it.quality.contains(quality) }
.thenByDescending { it.quality.contains(server, true) },
)
}
/* ====================================== Preferences ====================================== */
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = "Preferred domain"
entries = PREF_DOMAIN_ENTRIES
entryValues = PREF_DOMAIN_ENTRY_VALUES
setDefaultValue(PREF_DOMAIN_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 changes", Toast.LENGTH_LONG).show()
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_SERVER_KEY
title = "Preferred server"
entries = PREF_SERVER_ENTRIES
entryValues = PREF_SERVER_ENTRY_VALUES
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)
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = "Preferred quality"
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_ENTRY_VALUES
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_TYPE_KEY
title = "Preferred type"
entries = PREF_TYPE_ENTRIES
entryValues = PREF_TYPE_ENTRY_VALUES
setDefaultValue(PREF_TYPE_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_TITLE_LANGUAGE_KEY
title = "Preferred title language"
entries = PREF_TITLE_LANGUAGE_ENTRIES
entryValues = PREF_TITLE_LANGUAGE_ENTRY_VALUES
setDefaultValue(PREF_TITLE_LANGUAGE_DEFAULT)
summary = "%s"
setOnPreferenceChangeListener { _, newValue ->
val selected = newValue as String
val index = findIndexOfValue(selected)
val entry = entryValues[index] as String
Toast.makeText(screen.context, "Refresh your anime library to apply changes", Toast.LENGTH_LONG).show()
preferences.edit().putString(key, entry).commit()
}
}.also(screen::addPreference)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_MARK_FILLER_EPISODE_KEY
title = "Mark filler episodes"
setDefaultValue(PREF_MARK_FILLER_EPISODE_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
Toast.makeText(screen.context, "Refresh your anime library to apply changes", Toast.LENGTH_LONG).show()
preferences.edit().putBoolean(key, newValue as Boolean).commit()
}
}.also(screen::addPreference)
}
/* =================================== AniPlay Utilities =================================== */
private fun parseEpisodeName(number: Int, name: String): String {
return when {
listOf("EP ", "EPISODE ").any(name::startsWith) -> "Episode $number"
else -> "Episode $number: $name"
}
}
private fun getServerName(value: String): String {
val index = PREF_SERVER_ENTRY_VALUES.indexOf(value)
return PREF_SERVER_ENTRIES[index]
}
private fun getTypeName(value: String): String {
val index = PREF_TYPE_ENTRY_VALUES.indexOf(value)
return PREF_TYPE_ENTRIES[index]
}
@Synchronized
private fun parseDate(dateStr: String?): Long {
return dateStr?.let {
runCatching { DATE_FORMATTER.parse(it)?.time }.getOrNull()
} ?: 0L
}
companion object {
private const val PREF_DOMAIN_KEY = "domain"
private val PREF_DOMAIN_ENTRIES = arrayOf("aniplaynow.live (default)", "aniplay.lol (backup)")
private val PREF_DOMAIN_ENTRY_VALUES = arrayOf("aniplaynow.live", "aniplay.lol")
private const val PREF_DOMAIN_DEFAULT = "aniplaynow.live"
private const val PREF_SERVER_KEY = "server"
private val PREF_SERVER_ENTRIES = arrayOf("Kuro (Gogoanime)", "Yuki (HiAnime)", "Yuno (Yugenanime)")
private val PREF_SERVER_ENTRY_VALUES = arrayOf("kuro", "yuki", "yuno")
private const val PREF_SERVER_DEFAULT = "kuro"
private const val PREF_QUALITY_KEY = "quality"
private val PREF_QUALITY_ENTRIES = arrayOf("1080p", "720p", "480p", "360p")
private val PREF_QUALITY_ENTRY_VALUES = arrayOf("1080", "720", "480", "360")
private const val PREF_QUALITY_DEFAULT = "1080"
private const val PREF_TYPE_KEY = "type"
private val PREF_TYPE_ENTRIES = arrayOf("Sub", "SoftSub", "Dub")
private val PREF_TYPE_ENTRY_VALUES = arrayOf("sub", "softsub", "dub")
private const val PREF_TYPE_DEFAULT = "sub"
private const val PREF_TITLE_LANGUAGE_KEY = "title_language"
private val PREF_TITLE_LANGUAGE_ENTRIES = arrayOf("Romaji", "English", "Native")
private val PREF_TITLE_LANGUAGE_ENTRY_VALUES = arrayOf("romaji", "english", "native")
private const val PREF_TITLE_LANGUAGE_DEFAULT = "romaji"
private const val PREF_MARK_FILLER_EPISODE_KEY = "mark_filler_episode"
private const val PREF_MARK_FILLER_EPISODE_DEFAULT = true
private val DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
}

View file

@ -0,0 +1,69 @@
package eu.kanade.tachiyomi.animeextension.en.aniplay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class EpisodeListResponse(
val episodes: List<Episode>,
val providerId: String,
val default: Boolean?,
) {
@Serializable
data class Episode(
val id: String,
val number: Int,
val title: String,
val hasDub: Boolean,
val isFiller: Boolean,
val img: String?,
val description: String?,
val createdAt: String?,
)
}
@Serializable
data class VideoSourceRequest(
val source: String,
@SerialName("episodeid")
val episodeId: String,
@SerialName("episodenum")
val episodeNum: String,
@SerialName("subtype")
val subType: String,
)
@Serializable
data class VideoSourceResponse(
val sources: List<Source>?,
val subtitles: List<Subtitle>?,
) {
@Serializable
data class Source(
val url: String,
val quality: String,
)
@Serializable
data class Subtitle(
val url: String,
val lang: String,
)
}
@Serializable
data class EpisodeExtra(
val source: String,
val episodeId: String,
val hasDub: Boolean,
)
@Serializable
data class EpisodeData(
val source: String,
val language: String,
val response: VideoSourceResponse,
)