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,11 @@
plugins {
id("lib-multisrc")
}
baseVersionCode = 20
dependencies {
api(project(":lib:dood-extractor"))
api(project(":lib:cryptoaes"))
api(project(":lib:playlist-utils"))
}

View file

@ -0,0 +1,374 @@
package eu.kanade.tachiyomi.multisrc.dopeflix
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.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.animesource.online.ParsedAnimeHttpSource
import eu.kanade.tachiyomi.lib.doodextractor.DoodExtractor
import eu.kanade.tachiyomi.lib.playlistutils.PlaylistUtils
import eu.kanade.tachiyomi.multisrc.dopeflix.dto.VideoDto
import eu.kanade.tachiyomi.multisrc.dopeflix.extractors.DopeFlixExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parallelCatchingFlatMapBlocking
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
abstract class DopeFlix(
override val name: String,
override val lang: String,
private val domainArray: Array<String>,
private val defaultDomain: String,
) : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val baseUrl by lazy {
"https://" + preferences.getString(PREF_DOMAIN_KEY, defaultDomain)!!
}
override val supportsLatest = true
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun headersBuilder() = super.headersBuilder().add("Referer", "$baseUrl/")
// ============================== Popular ===============================
override fun popularAnimeSelector(): String = "div.film_list-wrap div.flw-item div.film-poster"
override fun popularAnimeRequest(page: Int): Request {
val type = preferences.getString(PREF_POPULAR_KEY, PREF_POPULAR_DEFAULT)!!
return GET("$baseUrl/$type?page=$page")
}
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
val ahref = element.selectFirst("a")!!
setUrlWithoutDomain(ahref.attr("href"))
title = ahref.attr("title")
thumbnail_url = element.selectFirst("img")!!.attr("data-src")
}
override fun popularAnimeNextPageSelector() = "ul.pagination li.page-item a[title=next]"
// =============================== Latest ===============================
override fun latestUpdatesNextPageSelector(): String? = null
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesRequest(page: Int): Request = GET("$baseUrl/home/")
override fun latestUpdatesSelector(): String {
val sectionLabel = preferences.getString(PREF_LATEST_KEY, PREF_LATEST_DEFAULT)!!
return "section.block_area:has(h2.cat-heading:contains($sectionLabel)) div.film-poster"
}
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()
override fun searchAnimeSelector() = popularAnimeSelector()
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val params = DopeFlixFilters.getSearchParameters(filters)
val url = if (query.isNotBlank()) {
val fixedQuery = query.replace(" ", "-")
"$baseUrl/search/$fixedQuery?page=$page"
} else {
"$baseUrl/filter".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("type", params.type)
.addQueryParameter("quality", params.quality)
.addQueryParameter("release_year", params.releaseYear)
.addIfNotBlank("genre", params.genres)
.addIfNotBlank("country", params.countries)
.build()
.toString()
}
return GET(url, headers)
}
override fun getFilterList() = DopeFlixFilters.FILTER_LIST
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
thumbnail_url = document.selectFirst("img.film-poster-img")!!.attr("src")
title = document.selectFirst("img.film-poster-img")!!.attr("title")
genre = document.select("div.row-line:contains(Genre) a").eachText().joinToString()
description = document.selectFirst("div.detail_page-watch div.description")!!
.text().replace("Overview:", "")
author = document.select("div.row-line:contains(Production) a").eachText().joinToString()
status = parseStatus(document.selectFirst("li.status span.value")?.text())
}
private fun parseStatus(statusString: String?): Int {
return when (statusString?.trim()) {
"Ongoing" -> SAnime.ONGOING
else -> SAnime.COMPLETED
}
}
// ============================== Episodes ==============================
override fun episodeListSelector() = throw UnsupportedOperationException()
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val infoElement = document.selectFirst("div.detail_page-watch")!!
val id = infoElement.attr("data-id")
val dataType = infoElement.attr("data-type") // Tv = 2 or movie = 1
return if (dataType == "2") {
val seasonUrl = "$baseUrl/ajax/v2/tv/seasons/$id"
val seasonsHtml = client.newCall(
GET(
seasonUrl,
headers = Headers.headersOf("Referer", document.location()),
),
).execute().asJsoup()
seasonsHtml
.select("a.dropdown-item.ss-item")
.flatMap(::parseEpisodesFromSeries)
.reversed()
} else {
val movieUrl = "$baseUrl/ajax/movie/episodes/$id"
SEpisode.create().apply {
name = document.selectFirst("h2.heading-name")!!.text()
episode_number = 1F
setUrlWithoutDomain(movieUrl)
}.let(::listOf)
}
}
override fun episodeFromElement(element: Element): SEpisode = throw UnsupportedOperationException()
private fun parseEpisodesFromSeries(element: Element): List<SEpisode> {
val seasonId = element.attr("data-id")
val seasonName = element.text()
val episodesUrl = "$baseUrl/ajax/v2/season/episodes/$seasonId"
val episodesHtml = client.newCall(GET(episodesUrl)).execute()
.asJsoup()
val episodeElements = episodesHtml.select("div.eps-item")
return episodeElements.map { episodeFromElement(it, seasonName) }
}
private fun episodeFromElement(element: Element, seasonName: String) = SEpisode.create().apply {
val episodeId = element.attr("data-id")
val epNum = element.selectFirst("div.episode-number")!!.text()
val epName = element.selectFirst("h3.film-name a")!!.text()
name = "$seasonName $epNum $epName"
episode_number = "${seasonName.getNumber()}.${epNum.getNumber().padStart(3, '0')}".toFloatOrNull() ?: 1F
setUrlWithoutDomain("$baseUrl/ajax/v2/episode/servers/$episodeId")
}
private fun String.getNumber() = filter(Char::isDigit)
// ============================ Video Links =============================
private val extractor by lazy { DopeFlixExtractor(client) }
private val playlistUtils by lazy { PlaylistUtils(client, headers) }
override fun videoListParse(response: Response): List<Video> {
val doc = response.asJsoup()
val episodeReferer = Headers.headersOf("Referer", response.request.header("referer")!!)
return doc.select("ul.fss-list a.btn-play")
.parallelCatchingFlatMapBlocking { server ->
val name = server.selectFirst("span")!!.text()
val id = server.attr("data-id")
val url = "$baseUrl/ajax/sources/$id"
val reqBody = client.newCall(GET(url, episodeReferer)).execute()
.body.string()
val sourceUrl = reqBody.substringAfter("\"link\":\"")
.substringBefore("\"")
when {
"DoodStream" in name ->
DoodExtractor(client).videoFromUrl(sourceUrl)
?.let(::listOf)
"Vidcloud" in name || "UpCloud" in name -> {
val video = extractor.getVideoDto(sourceUrl)
getVideosFromServer(video, name)
}
else -> null
}.orEmpty()
}
}
private fun getVideosFromServer(video: VideoDto, name: String): List<Video> {
val masterUrl = video.sources.first().file
val subs = video.tracks
?.filter { it.kind == "captions" }
?.mapNotNull { Track(it.file, it.label) }
?.let(::subLangOrder)
?: emptyList<Track>()
if (masterUrl.contains("playlist.m3u8")) {
return playlistUtils.extractFromHls(
masterUrl,
videoNameGen = { "$name - $it" },
subtitleList = subs,
)
}
return listOf(
Video(masterUrl, "$name - Default", masterUrl, subtitleTracks = subs),
)
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
compareBy(
{ it.quality.contains(quality) }, // preferred quality first
// then group by quality
{ Regex("""(\d+)p""").find(it.quality)?.groupValues?.get(1)?.toIntOrNull() ?: 0 },
),
).reversed()
}
private fun subLangOrder(tracks: List<Track>): List<Track> {
val language = preferences.getString(PREF_SUB_KEY, PREF_SUB_DEFAULT)!!
return tracks.sortedWith(
compareBy { it.lang.contains(language) },
).reversed()
}
override fun videoListSelector() = throw UnsupportedOperationException()
override fun videoFromElement(element: Element) = throw UnsupportedOperationException()
override fun videoUrlParse(document: Document) = throw UnsupportedOperationException()
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_DOMAIN_KEY
title = PREF_DOMAIN_TITLE
entries = domainArray
entryValues = domainArray
setDefaultValue(defaultDomain)
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 = PREF_QUALITY_TITLE
entries = PREF_QUALITY_LIST
entryValues = PREF_QUALITY_LIST
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_SUB_KEY
title = PREF_SUB_TITLE
entries = PREF_SUB_LANGUAGES
entryValues = PREF_SUB_LANGUAGES
setDefaultValue(PREF_SUB_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_LATEST_KEY
title = PREF_LATEST_TITLE
entries = PREF_LATEST_PAGES
entryValues = PREF_LATEST_PAGES
setDefaultValue(PREF_LATEST_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_POPULAR_KEY
title = PREF_POPULAR_TITLE
entries = PREF_POPULAR_ENTRIES
entryValues = PREF_POPULAR_VALUES
setDefaultValue(PREF_POPULAR_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)
}
// ============================= Utilities ==============================
private fun HttpUrl.Builder.addIfNotBlank(query: String, value: String): HttpUrl.Builder {
if (value.isNotBlank()) {
addQueryParameter(query, value)
}
return this
}
companion object {
private const val PREF_DOMAIN_KEY = "preferred_domain_new"
private const val PREF_DOMAIN_TITLE = "Preferred domain (requires app restart)"
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "1080p"
private val PREF_QUALITY_LIST = arrayOf("1080p", "720p", "480p", "360p")
private const val PREF_SUB_KEY = "preferred_subLang"
private const val PREF_SUB_TITLE = "Preferred sub language"
private const val PREF_SUB_DEFAULT = "English"
private val PREF_SUB_LANGUAGES = arrayOf(
"Arabic", "English", "French", "German", "Hungarian",
"Italian", "Japanese", "Portuguese", "Romanian", "Russian",
"Spanish",
)
private const val PREF_LATEST_KEY = "preferred_latest_page"
private const val PREF_LATEST_TITLE = "Preferred latest page"
private const val PREF_LATEST_DEFAULT = "Movies"
private val PREF_LATEST_PAGES = arrayOf("Movies", "TV Shows")
private const val PREF_POPULAR_KEY = "preferred_popular_page_new"
private const val PREF_POPULAR_TITLE = "Preferred popular page"
private const val PREF_POPULAR_DEFAULT = "movie"
private val PREF_POPULAR_ENTRIES = PREF_LATEST_PAGES
private val PREF_POPULAR_VALUES = arrayOf("movie", "tv-show")
}
}

View file

@ -0,0 +1,178 @@
package eu.kanade.tachiyomi.multisrc.dopeflix
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object DopeFlixFilters {
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 (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 }
.joinToString("-") { it.ifBlank { "all" } }
}
class TypeFilter : QueryPartFilter("Type", DopeFlixFiltersData.TYPES)
class QualityFilter : QueryPartFilter("Quality", DopeFlixFiltersData.QUALITIES)
class ReleaseYearFilter : QueryPartFilter("Released at", DopeFlixFiltersData.YEARS)
class GenresFilter : CheckBoxFilterList(
"Genres",
DopeFlixFiltersData.GENRES.map { CheckBoxVal(it.first, false) },
)
class CountriesFilter : CheckBoxFilterList(
"Countries",
DopeFlixFiltersData.COUNTRIES.map { CheckBoxVal(it.first, false) },
)
val FILTER_LIST get() = AnimeFilterList(
TypeFilter(),
QualityFilter(),
ReleaseYearFilter(),
AnimeFilter.Separator(),
GenresFilter(),
CountriesFilter(),
)
data class FilterSearchParams(
val type: String = "",
val quality: String = "",
val releaseYear: String = "",
val genres: String = "",
val countries: String = "",
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
return FilterSearchParams(
filters.asQueryPart<TypeFilter>(),
filters.asQueryPart<QualityFilter>(),
filters.asQueryPart<ReleaseYearFilter>(),
filters.parseCheckbox<GenresFilter>(DopeFlixFiltersData.GENRES),
filters.parseCheckbox<CountriesFilter>(DopeFlixFiltersData.COUNTRIES),
)
}
private object DopeFlixFiltersData {
val ALL = Pair("All", "all")
val TYPES = arrayOf(
ALL,
Pair("Movies", "movies"),
Pair("TV Shows", "tv"),
)
val QUALITIES = arrayOf(
ALL,
Pair("HD", "HD"),
Pair("SD", "SD"),
Pair("CAM", "CAM"),
)
val YEARS = arrayOf(
ALL,
Pair("2024", "2024"),
Pair("2023", "2023"),
Pair("2022", "2022"),
Pair("2021", "2021"),
Pair("2020", "2020"),
Pair("2019", "2019"),
Pair("2018", "2018"),
Pair("Older", "older-2018"),
)
val GENRES = arrayOf(
Pair("Action", "10"),
Pair("Action & Adventure", "24"),
Pair("Adventure", "18"),
Pair("Animation", "3"),
Pair("Biography", "37"),
Pair("Comedy", "7"),
Pair("Crime", "2"),
Pair("Documentary", "11"),
Pair("Drama", "4"),
Pair("Family", "9"),
Pair("Fantasy", "13"),
Pair("History", "19"),
Pair("Horror", "14"),
Pair("Kids", "27"),
Pair("Music", "15"),
Pair("Mystery", "1"),
Pair("News", "34"),
Pair("Reality", "22"),
Pair("Romance", "12"),
Pair("Sci-Fi & Fantasy", "31"),
Pair("Science Fiction", "5"),
Pair("Soap", "35"),
Pair("Talk", "29"),
Pair("Thriller", "16"),
Pair("TV Movie", "8"),
Pair("War", "17"),
Pair("War & Politics", "28"),
Pair("Western", "6"),
)
val COUNTRIES = arrayOf(
Pair("Argentina", "11"),
Pair("Australia", "151"),
Pair("Austria", "4"),
Pair("Belgium", "44"),
Pair("Brazil", "190"),
Pair("Canada", "147"),
Pair("China", "101"),
Pair("Czech Republic", "231"),
Pair("Denmark", "222"),
Pair("Finland", "158"),
Pair("France", "3"),
Pair("Germany", "96"),
Pair("Hong Kong", "93"),
Pair("Hungary", "72"),
Pair("India", "105"),
Pair("Ireland", "196"),
Pair("Israel", "24"),
Pair("Italy", "205"),
Pair("Japan", "173"),
Pair("Luxembourg", "91"),
Pair("Mexico", "40"),
Pair("Netherlands", "172"),
Pair("New Zealand", "122"),
Pair("Norway", "219"),
Pair("Poland", "23"),
Pair("Romania", "170"),
Pair("Russia", "109"),
Pair("South Africa", "200"),
Pair("South Korea", "135"),
Pair("Spain", "62"),
Pair("Sweden", "114"),
Pair("Switzerland", "41"),
Pair("Taiwan", "119"),
Pair("Thailand", "57"),
Pair("United Kingdom", "180"),
Pair("United States of America", "129"),
)
}
}

View file

@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.multisrc.dopeflix.dto
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@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 = "")

View file

@ -0,0 +1,112 @@
package eu.kanade.tachiyomi.multisrc.dopeflix.extractors
import eu.kanade.tachiyomi.lib.cryptoaes.CryptoAES
import eu.kanade.tachiyomi.multisrc.dopeflix.dto.SourceResponseDto
import eu.kanade.tachiyomi.multisrc.dopeflix.dto.VideoDto
import eu.kanade.tachiyomi.multisrc.dopeflix.dto.VideoLink
import eu.kanade.tachiyomi.network.GET
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.OkHttpClient
import uy.kohesive.injekt.injectLazy
class DopeFlixExtractor(private val client: OkHttpClient) {
private val json: Json by injectLazy()
companion object {
private const val SOURCES_PATH = "/ajax/embed-4/getSources?id="
private const val SCRIPT_URL = "https://rabbitstream.net/js/player/prod/e4-player.min.js"
private val MUTEX = Mutex()
private var realIndexPairs: List<List<Int>> = emptyList()
private fun <R> runLocked(block: () -> R) = runBlocking(Dispatchers.IO) {
MUTEX.withLock { block() }
}
}
private fun generateIndexPairs(): List<List<Int>> {
val script = client.newCall(GET(SCRIPT_URL)).execute().body.string()
return script.substringAfter("const ")
.substringBefore("()")
.substringBeforeLast(",")
.split(",")
.map {
val value = it.substringAfter("=")
when {
value.contains("0x") -> value.substringAfter("0x").toInt(16)
else -> value.toInt()
}
}
.drop(1)
.chunked(2)
.map(List<Int>::reversed) // just to look more like the original script
}
private fun cipherTextCleaner(data: String): Pair<String, String> {
val (password, ciphertext, _) = indexPairs.fold(Triple("", data, 0)) { previous, item ->
val start = item.first() + previous.third
val end = start + item.last()
val passSubstr = data.substring(start, end)
val passPart = previous.first + passSubstr
val cipherPart = previous.second.replace(passSubstr, "")
Triple(passPart, cipherPart, previous.third + item.last())
}
return Pair(ciphertext, password)
}
private val mutex = Mutex()
private var indexPairs: List<List<Int>>
get() {
return runLocked {
if (realIndexPairs.isEmpty()) {
realIndexPairs = generateIndexPairs()
}
realIndexPairs
}
}
set(value) {
runLocked {
if (realIndexPairs.isNotEmpty()) {
realIndexPairs = value
}
}
}
private fun tryDecrypting(ciphered: String, attempts: Int = 0): String {
if (attempts > 2) throw Exception("PLEASE NUKE DOPEBOX AND SFLIX")
val (ciphertext, password) = cipherTextCleaner(ciphered)
return CryptoAES.decrypt(ciphertext, password).ifEmpty {
indexPairs = emptyList() // force re-creation
tryDecrypting(ciphered, attempts + 1)
}
}
fun getVideoDto(url: String): VideoDto {
val id = url.substringAfter("/embed-4/", "")
.substringBefore("?", "").ifEmpty { throw Exception("I HATE THE ANTICHRIST") }
val serverUrl = url.substringBefore("/embed")
val srcRes = client.newCall(
GET(
serverUrl + SOURCES_PATH + id,
headers = Headers.headersOf("x-requested-with", "XMLHttpRequest"),
),
)
.execute()
.body.string()
val data = json.decodeFromString<SourceResponseDto>(srcRes)
if (!data.encrypted) return json.decodeFromString<VideoDto>(srcRes)
val ciphered = data.sources.jsonPrimitive.content.toString()
val decrypted = json.decodeFromString<List<VideoLink>>(tryDecrypting(ciphered))
return VideoDto(decrypted, data.tracks)
}
}