Enable multisrc support (#1352)

* Re-add multisrc support

* Convert SFlix and DopeBox to multisrc

* Re-enable multisrc tasks on workflows
This commit is contained in:
Claudemirovsky
2023-03-02 08:37:11 -03:00
committed by GitHub
parent d42f5c6cc6
commit ec19a31c3d
39 changed files with 756 additions and 1209 deletions

View File

@ -0,0 +1,435 @@
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.AnimesPage
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.multisrc.dopeflix.dto.VideoDto
import eu.kanade.tachiyomi.multisrc.dopeflix.extractors.DopeFlixExtractor
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import rx.Observable
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
override val client: OkHttpClient = network.cloudflareClient
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val json = Json {
ignoreUnknownKeys = true
}
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.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 {
val anime = SAnime.create()
anime.setUrlWithoutDomain(element.selectFirst("a")!!.attr("href"))
anime.thumbnail_url = element.selectFirst("img")!!.attr("data-src")
anime.title = element.selectFirst("a")!!.attr("title")
return anime
}
override fun popularAnimeNextPageSelector(): String = "ul.pagination li.page-item a[title=next]"
// ============================== Episodes ==============================
override fun episodeListSelector() = throw Exception("not used")
override fun episodeListParse(response: Response): List<SEpisode> {
val document = response.asJsoup()
val episodeList = mutableListOf<SEpisode>()
val infoElement = document.select("div.detail_page-watch")
val id = infoElement.attr("data-id")
val dataType = infoElement.attr("data-type") // Tv = 2 or movie = 1
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()
val seasonsElements = seasonsHtml.select("a.dropdown-item.ss-item")
seasonsElements.forEach {
val seasonEpList = parseEpisodesFromSeries(it)
episodeList.addAll(seasonEpList)
}
} else {
val movieUrl = "$baseUrl/ajax/movie/episodes/$id"
val episode = SEpisode.create()
episode.name = document.select("h2.heading-name").text()
episode.episode_number = 1F
episode.setUrlWithoutDomain(movieUrl)
episodeList.add(episode)
}
return episodeList.reversed()
}
override fun episodeFromElement(element: Element): SEpisode = throw Exception("not used")
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 {
val episodeId = element.attr("data-id")
val epNum = element.selectFirst("div.episode-number")!!.text()
val epName = element.selectFirst("h3.film-name a")!!.text()
val episode = SEpisode.create().apply {
name = "$seasonName $epNum $epName"
setUrlWithoutDomain("$baseUrl/ajax/v2/episode/servers/$episodeId")
}
return episode
}
private fun getNumberFromEpsString(epsStr: String): String {
return epsStr.filter { it.isDigit() }
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val doc = response.asJsoup()
val episodeReferer = Headers.headersOf("Referer", response.request.header("referer")!!)
val extractor = DopeFlixExtractor(client)
val videoList = doc.select("ul.fss-list a.btn-play")
.parallelMap { 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("\"")
runCatching {
when {
"DoodStream" in name ->
DoodExtractor(client).videoFromUrl(sourceUrl)?.let {
listOf(it)
}
"Vidcloud" in name || "UpCloud" in name -> {
val source = extractor.getSourcesJson(sourceUrl)
source?.let { getVideosFromServer(it, name) }
}
else -> null
}
}.getOrNull()
}
.filterNotNull()
.flatten()
return videoList
}
private fun getVideosFromServer(source: String, name: String): List<Video>? {
if (!source.contains("{\"sources\":[{\"file\":\"")) return null
val response = json.decodeFromString<VideoDto>(source)
val masterUrl = response.sources.first().file
val subs2 = response.tracks
?.filter { it.kind == "captions" }
?.mapNotNull {
runCatching { Track(it.file, it.label) }.getOrNull()
} ?: emptyList<Track>()
val subs = subLangOrder(subs2)
if (masterUrl.contains("playlist.m3u8")) {
val prefix = "#EXT-X-STREAM-INF:"
val playlist = client.newCall(GET(masterUrl)).execute()
.body.string()
val videoList = playlist.substringAfter(prefix).split(prefix).map {
val quality = "$name - " + it.substringAfter("RESOLUTION=")
.substringAfter("x")
.substringBefore("\n")
.substringBefore(",") + "p"
val videoUrl = it.substringAfter("\n").substringBefore("\n")
try {
Video(videoUrl, quality, videoUrl, subtitleTracks = subs)
} catch (e: Error) {
Video(videoUrl, quality, videoUrl)
}
}
return videoList
}
val defaultVideoList = listOf(
try {
Video(masterUrl, "$name - Default", masterUrl, subtitleTracks = subs)
} catch (e: Error) {
Video(masterUrl, "$name - Default", masterUrl)
},
)
return defaultVideoList
}
override fun List<Video>.sort(): List<Video> {
val quality = preferences.getString(PREF_QUALITY_KEY, PREF_QUALITY_DEFAULT)!!
return sortedWith(
compareBy { it.quality.contains(quality) },
).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 Exception("not used")
override fun videoFromElement(element: Element) = throw Exception("not used")
override fun videoUrlParse(document: Document) = throw Exception("not used")
// =============================== Search ===============================
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector(): String = popularAnimeNextPageSelector()
override fun searchAnimeSelector(): String = popularAnimeSelector()
override fun fetchSearchAnime(page: Int, query: String, filters: AnimeFilterList): Observable<AnimesPage> {
val params = DopeFlixFilters.getSearchParameters(filters)
return client.newCall(searchAnimeRequest(page, query, params))
.asObservableSuccess()
.map { response ->
searchAnimeParse(response)
}
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request = throw Exception("not used")
private fun searchAnimeRequest(page: Int, query: String, filters: DopeFlixFilters.FilterSearchParams): Request {
val url = if (query.isNotBlank()) {
val fixedQuery = query.replace(" ", "-")
"$baseUrl/search/$fixedQuery?page=$page"
} else {
"$baseUrl/filter?".toHttpUrlOrNull()!!.newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("type", filters.type)
.addQueryParameter("quality", filters.quality)
.addQueryParameter("release_year", filters.releaseYear)
.addQueryParameter("genre", filters.genres)
.addQueryParameter("country", filters.countries)
.build()
.toString()
}
return GET(url, headers)
}
override fun getFilterList(): AnimeFilterList = DopeFlixFilters.filterList
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document): SAnime {
val anime = 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")
.joinToString(", ") { it.text() }
description = document.selectFirst("div.detail_page-watch div.description")!!
.text().replace("Overview:", "")
author = document.select("div.row-line:contains(Production) a")
.joinToString(", ") { it.text() }
status = parseStatus(document.select("li.status span.value").text())
}
return anime
}
private fun parseStatus(statusString: String): Int {
return when (statusString) {
"Ongoing" -> SAnime.ONGOING
else -> SAnime.COMPLETED
}
}
// =============================== 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"
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
val domainPref = 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()
}
}
val videoQualityPref = 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()
}
}
val subLangPref = 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()
}
}
val latestType = 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()
}
}
val popularType = 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()
}
}
screen.addPreference(domainPref)
screen.addPreference(videoQualityPref)
screen.addPreference(subLangPref)
screen.addPreference(latestType)
screen.addPreference(popularType)
}
// ============================= Utilities ==============================
private fun <A, B> Iterable<A>.parallelMap(f: suspend (A) -> B): List<B> =
runBlocking {
map { async(Dispatchers.Default) { f(it) } }.awaitAll()
}
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,187 @@
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 (this.getFirst<R>() as QueryPartFilter).toQueryPart()
}
private inline fun <reified R> AnimeFilterList.getFirst(): R {
return this.filterIsInstance<R>().first()
}
private inline fun <reified R> AnimeFilterList.parseCheckbox(
options: Array<Pair<String, String>>,
): String {
return (this.getFirst<R>() as CheckBoxFilterList).state
.mapNotNull { checkbox ->
if (checkbox.state) {
options.find { it.first == checkbox.name }!!.second
} else {
null
}
}.joinToString("-").let {
if (it.isBlank()) {
"all"
} else {
it
}
}
}
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 filterList = 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("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,22 @@
package eu.kanade.tachiyomi.multisrc.dopeflix
import generator.ThemeSourceData.SingleLang
import generator.ThemeSourceGenerator
class DopeFlixGenerator : ThemeSourceGenerator {
override val themePkg = "dopeflix"
override val themeClass = "DopeFlix"
override val baseVersionCode = 16
override val sources = listOf(
SingleLang("DopeBox", "https://dopebox.to", "en", isNsfw = false, overrideVersionCode = 1),
SingleLang("SFlix", "https://sflix.to", "en", isNsfw = false),
)
companion object {
@JvmStatic
fun main(args: Array<String>) = DopeFlixGenerator().createAll()
}
}

View File

@ -0,0 +1,15 @@
package eu.kanade.tachiyomi.multisrc.dopeflix.dto
import kotlinx.serialization.Serializable
@Serializable
data class VideoDto(
val sources: List<VideoLink>,
val tracks: List<TrackDto>?,
)
@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,39 @@
package eu.kanade.tachiyomi.multisrc.dopeflix.extractors
import eu.kanade.tachiyomi.multisrc.dopeflix.utils.Decryptor
import eu.kanade.tachiyomi.network.GET
import okhttp3.Headers
import okhttp3.OkHttpClient
class DopeFlixExtractor(private val client: OkHttpClient) {
companion object {
private const val SOURCES_PATH = "/ajax/embed-4/getSources?id="
}
fun getSourcesJson(url: String): String? {
val id = url.substringAfter("/embed-4/", "")
.substringBefore("?", "").ifEmpty { return null }
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 key = client.newCall(GET("https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt"))
.execute()
.body.string()
// encrypted data will start with "U2Fsd..." because they put
// "Salted__" at the start of encrypted data, thanks openssl
// if its not encrypted, then return it
if ("\"sources\":\"U2FsdGVk" !in srcRes) return srcRes
if (!srcRes.contains("{\"sources\":")) return null
val encrypted = srcRes.substringAfter("sources\":\"").substringBefore("\"")
val decrypted = Decryptor.decrypt(encrypted, key) ?: return null
val end = srcRes.replace("\"$encrypted\"", decrypted)
return end
}
}

View File

@ -0,0 +1,74 @@
package eu.kanade.tachiyomi.multisrc.dopeflix.utils
import android.util.Base64
import java.security.DigestException
import java.security.MessageDigest
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
object Decryptor {
fun decrypt(encodedData: String, remoteKey: String): String? {
val saltedData = Base64.decode(encodedData, Base64.DEFAULT)
val salt = saltedData.copyOfRange(8, 16)
val ciphertext = saltedData.copyOfRange(16, saltedData.size)
val password = remoteKey.toByteArray()
val (key, iv) = GenerateKeyAndIv(password, salt) ?: return null
val keySpec = SecretKeySpec(key, "AES")
val ivSpec = IvParameterSpec(iv)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec)
val decryptedData = String(cipher.doFinal(ciphertext))
return decryptedData
}
// https://stackoverflow.com/a/41434590/8166854
private fun GenerateKeyAndIv(
password: ByteArray,
salt: ByteArray,
hashAlgorithm: String = "MD5",
keyLength: Int = 32,
ivLength: Int = 16,
iterations: Int = 1,
): List<ByteArray>? {
val md = MessageDigest.getInstance(hashAlgorithm)
val digestLength = md.getDigestLength()
val targetKeySize = keyLength + ivLength
val requiredLength = (targetKeySize + digestLength - 1) / digestLength * digestLength
var generatedData = ByteArray(requiredLength)
var generatedLength = 0
try {
md.reset()
while (generatedLength < targetKeySize) {
if (generatedLength > 0) {
md.update(
generatedData,
generatedLength - digestLength,
digestLength,
)
}
md.update(password)
md.update(salt, 0, 8)
md.digest(generatedData, generatedLength, digestLength)
for (i in 1 until iterations) {
md.update(generatedData, generatedLength, digestLength)
md.digest(generatedData, generatedLength, digestLength)
}
generatedLength += digestLength
}
val result = listOf(
generatedData.copyOfRange(0, keyLength),
generatedData.copyOfRange(keyLength, targetKeySize),
)
return result
} catch (e: DigestException) {
return null
}
}
}

View File

@ -0,0 +1,28 @@
package generator
import java.io.File
/**
* Finds and calls all `ThemeSourceGenerator`s
*/
fun main(args: Array<String>) {
val userDir = System.getProperty("user.dir")!!
val sourcesDirPath = "$userDir/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc"
val sourcesDir = File(sourcesDirPath)
// find all theme packages
sourcesDir.list()!!
.filter { File(sourcesDir, it).isDirectory }
.forEach { themeSource ->
// Find all XxxGenerator.kt files and invoke main from them
File("$sourcesDirPath/$themeSource").list()!!
.filter { it.endsWith("Generator.kt") }
.mapNotNull { generatorClass ->
// Find Java class and extract method lists
Class.forName("eu/kanade/tachiyomi/multisrc/$themeSource/$generatorClass".replace("/", ".").substringBefore(".kt"))
.methods
.find { it.name == "main" }
}
.forEach { it.invoke(null, emptyArray<String>()) }
}
}

View File

@ -0,0 +1,49 @@
package generator
import java.io.File
/**
* Finds all themes and creates an Intellij Idea run configuration for their generators
* Should be run after creation/deletion of each theme
*/
fun main(args: Array<String>) {
val userDir = System.getProperty("user.dir")!!
val sourcesDirPath = "$userDir/multisrc/src/main/java/eu/kanade/tachiyomi/multisrc"
val sourcesDir = File(sourcesDirPath)
// cleanup from past runs
File("$userDir/.run").apply {
if (exists()) {
deleteRecursively()
}
mkdirs()
}
// find all theme packages
sourcesDir.list()!!
.filter { File(sourcesDir, it).isDirectory }
.forEach { themeSource ->
// Find all XxxGenerator.kt files
File("$sourcesDirPath/$themeSource").list()!!
.filter { it.endsWith("Generator.kt") }
.map { it.substringBefore(".kt") }
.forEach { generatorClass ->
val file = File("$userDir/.run/$generatorClass.run.xml")
val intellijConfStr = """
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="$generatorClass" type="JetRunConfigurationType" nameIsGenerated="true">
<module name="aniyomi-extensions.multisrc.main" />
<option name="MAIN_CLASS_NAME" value="eu.kanade.tachiyomi.multisrc.$themeSource.$generatorClass" />
<method v="2">
<option name="Make" enabled="true" />
<option name="Gradle.BeforeRunTask" enabled="true" tasks="ktFormat" externalProjectPath="${'$'}PROJECT_DIR${'$'}/multisrc" vmOptions="" scriptParameters="-Ptheme=$themeSource" />
<option name="Gradle.BeforeRunTask" enabled="true" tasks="ktLint" externalProjectPath="${'$'}PROJECT_DIR${'$'}/multisrc" vmOptions="" scriptParameters="-Ptheme=$themeSource" />
</method>
</configuration>
</component>
""".trimIndent()
file.writeText(intellijConfStr)
file.appendText("\n")
}
}
}

View File

@ -0,0 +1,304 @@
package generator
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.io.File
import java.nio.file.Files
import java.nio.file.StandardCopyOption
import java.util.Locale
/**
* This is meant to be used in place of a factory extension, specifically for what would be a multi-source extension.
* A multi-lang (but not multi-source) extension should still be made as a factory extension.
* Use a generator for initial setup of a theme source or when all of the inheritors need a version bump.
* Source list (val sources) should be kept up to date.
*/
interface ThemeSourceGenerator {
/**
* The class that the sources inherit from.
*/
val themeClass: String
/**
* The package that contains themeClass.
*/
val themePkg: String
/**
* Base theme version, starts with 1 and should be increased when based theme class changes
*/
val baseVersionCode: Int
/**
* The list of sources to be created or updated.
*/
val sources: List<ThemeSourceData>
fun createAll() {
val userDir = System.getProperty("user.dir")!!
sources.forEach { createGradleProject(it, themePkg, themeClass, baseVersionCode, userDir) }
}
companion object {
private fun pkgNameSuffix(source: ThemeSourceData, separator: String): String {
return if (source is ThemeSourceData.SingleLang) {
listOf(source.lang.substringBefore("-"), source.pkgName).joinToString(separator)
} else {
listOf("all", source.pkgName).joinToString(separator)
}
}
private fun themeSuffix(themePkg: String, separator: String): String {
return listOf("eu", "kanade", "tachiyomi", "multisrc", themePkg).joinToString(separator)
}
private fun writeGradle(gradle: File, source: ThemeSourceData, themePkg: String, baseVersionCode: Int, defaultAdditionalGradlePath: String, additionalGradleOverridePath: String) {
fun File.readTextOrEmptyString(): String = if (exists()) readText(Charsets.UTF_8) else ""
val defaultAdditionalGradleText = File(defaultAdditionalGradlePath).readTextOrEmptyString()
val additionalGradleOverrideText = File(additionalGradleOverridePath).readTextOrEmptyString()
val placeholders = mapOf(
"SOURCEHOST" to source.baseUrl.toHttpUrlOrNull()?.host,
"SOURCESCHEME" to source.baseUrl.toHttpUrlOrNull()?.scheme,
)
val placeholdersStr = placeholders
.filter { it.value != null }
.map { "${" ".repeat(12)}${it.key}: \"${it.value}\"" }
.joinToString(",\n")
gradle.writeText(
"""
|// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
|apply plugin: 'com.android.application'
|apply plugin: 'kotlin-android'
|apply plugin: 'kotlinx-serialization'
|
|ext {
| extName = '${source.name}'
| pkgNameSuffix = '${pkgNameSuffix(source, ".")}'
| extClass = '.${source.className}'
| extFactory = '$themePkg'
| extVersionCode = ${baseVersionCode + source.overrideVersionCode + multisrcLibraryVersion}
| ${if (source.isNsfw) "containsNsfw = true\n" else ""}
|}
|$defaultAdditionalGradleText
|$additionalGradleOverrideText
|apply from: "${'$'}rootDir/common.gradle"
|
|android {
| defaultConfig {
| manifestPlaceholders += [
|$placeholdersStr
| ]
| }
|}
""".trimMargin(),
)
}
private fun writeAndroidManifest(androidManifestFile: File, manifestOverridesPath: String, defaultAndroidManifestPath: String) {
val androidManifestOverride = File(manifestOverridesPath)
val defaultAndroidManifest = File(defaultAndroidManifestPath)
if (androidManifestOverride.exists()) {
androidManifestOverride.copyTo(androidManifestFile)
} else if (defaultAndroidManifest.exists()) {
defaultAndroidManifest.copyTo(androidManifestFile)
} else {
androidManifestFile.writeText(
"""
|<?xml version="1.0" encoding="utf-8"?>
|<!-- THIS FILE IS AUTO-GENERATED; DO NOT EDIT -->
|<manifest package="eu.kanade.tachiyomi.animeextension" />
""".trimMargin(),
)
}
}
fun createGradleProject(source: ThemeSourceData, themePkg: String, themeClass: String, baseVersionCode: Int, userDir: String) {
// userDir = aniyomi-extensions project root path
val projectRootPath = "$userDir/generated-src/${pkgNameSuffix(source, "/")}"
val projectSrcPath = "$projectRootPath/src/eu/kanade/tachiyomi/animeextension/${pkgNameSuffix(source, "/")}"
val overridesPath = "$userDir/multisrc/overrides/$themePkg/${source.pkgName}"
val defaultResPath = "$userDir/multisrc/overrides/$themePkg/default/res"
val defaultAndroidManifestPath = "$userDir/multisrc/overrides/$themePkg/default/AndroidManifest.xml"
val defaultAdditionalGradlePath = "$userDir/multisrc/overrides/$themePkg/default/additional.gradle"
val resOverridePath = "$overridesPath/res"
val srcOverridePath = "$overridesPath/src"
val manifestOverridePath = "$overridesPath/AndroidManifest.xml"
val additionalGradleOverridePath = "$overridesPath/additional.gradle"
val projectGradleFile = File("$projectRootPath/build.gradle")
val projectAndroidManifestFile = File("$projectRootPath/AndroidManifest.xml")
File(projectRootPath).let { projectRootFile ->
println("Generating $source")
projectRootFile.mkdirs()
// remove everything from past runs
cleanDirectory(projectRootFile)
writeGradle(projectGradleFile, source, themePkg, baseVersionCode, defaultAdditionalGradlePath, additionalGradleOverridePath)
writeAndroidManifest(projectAndroidManifestFile, manifestOverridePath, defaultAndroidManifestPath)
writeSourceClasses(projectSrcPath, srcOverridePath, source, themePkg, themeClass)
copyThemeClasses(userDir, themePkg, projectRootPath)
copyThemeReadmes(userDir, themePkg, overridesPath, projectRootPath)
copyResFiles(resOverridePath, defaultResPath, source, projectRootPath)
}
}
private fun copyThemeReadmes(userDir: String, themePkg: String, overridesPath: String, projectRootPath: String) {
val sourcePath = "$userDir/multisrc/src/main/java/${themeSuffix(themePkg, "/")}"
File(projectRootPath).mkdirs()
listOf(sourcePath, overridesPath).forEach { path ->
File(path).list()
?.filter { it.endsWith("README.md") || it.endsWith("CHANGELOG.md") }
?.forEach {
Files.copy(
File("$path/$it").toPath(),
File("$projectRootPath/$it").toPath(),
StandardCopyOption.REPLACE_EXISTING,
)
}
}
}
private fun copyThemeClasses(userDir: String, themePkg: String, projectRootPath: String) {
val themeSrcPath = "$userDir/multisrc/src/main/java/${themeSuffix(themePkg, "/")}"
val themeDestPath = "$projectRootPath/src/${themeSuffix(themePkg, "/")}"
File(themeDestPath).mkdirs()
File(themeSrcPath).walk()
.map { it.toString().replace(themeSrcPath, "") }
.filter { it.endsWith(".kt") && !it.endsWith("Generator.kt") }
.forEach {
File("$themeSrcPath/$it").copyTo(
File("$themeDestPath/$it"),
true, // REPLACE_EXISTING
)
}
}
private fun copyResFiles(resOverridePath: String, defaultResPath: String, source: ThemeSourceData, projectRootPath: String): Any {
// check if res override exists if not copy default res
val resOverride = File(resOverridePath)
return if (resOverride.exists()) {
resOverride.copyRecursively(File("$projectRootPath/res"))
} else {
File(defaultResPath).let { defaultResFile ->
if (defaultResFile.exists()) defaultResFile.copyRecursively(File("$projectRootPath/res"))
}
}
}
private fun writeSourceClasses(projectSrcPath: String, srcOverridePath: String, source: ThemeSourceData, themePkg: String, themeClass: String) {
val projectSrcFile = File(projectSrcPath)
projectSrcFile.mkdirs()
val srcOverrideFile = File(srcOverridePath)
if (srcOverrideFile.exists()) {
srcOverrideFile.copyRecursively(projectSrcFile)
} else {
writeSourceClass(projectSrcFile, source, themePkg, themeClass)
}
}
private fun writeSourceClass(classPath: File, source: ThemeSourceData, themePkg: String, themeClass: String) {
fun factoryClassText() = when (source) {
is ThemeSourceData.SingleLang -> {
"""class ${source.className} : $themeClass("${source.sourceName}", "${source.baseUrl}", "${source.lang}")"""
}
is ThemeSourceData.MultiLang -> {
val sourceClasses = source.langs.map { lang ->
"""$themeClass("${source.sourceName}", "${source.baseUrl}", "$lang")"""
}
"""
|class ${source.className} : AnimeSourceFactory {
| override fun createSources() = listOf(
| ${sourceClasses.joinToString(",\n")}
| )
|}
""".trimMargin()
}
}
File("$classPath/${source.className}.kt").writeText(
"""
|/* ktlint-disable */
|// THIS FILE IS AUTO-GENERATED; DO NOT EDIT
|package eu.kanade.tachiyomi.animeextension.${pkgNameSuffix(source, ".")}
|
|import eu.kanade.tachiyomi.multisrc.$themePkg.$themeClass
|${if (source is ThemeSourceData.MultiLang) "import eu.kanade.tachiyomi.animesource.AnimeSourceFactory" else ""}
|
|${factoryClassText()}
""".trimMargin(),
)
}
private fun cleanDirectory(dir: File) {
dir.listFiles()?.forEach {
if (it.isDirectory) {
cleanDirectory(it)
}
it.delete()
}
}
}
}
sealed class ThemeSourceData {
abstract val name: String
abstract val baseUrl: String
abstract val isNsfw: Boolean
abstract val className: String
abstract val pkgName: String
/**
* Override it if for some reason the name attribute inside the source class
* should be different from the extension name. Useful in cases where the
* extension name should be romanized and the source name should be the one
* in the source language. Defaults to the extension name if not specified.
*/
abstract val sourceName: String
/**
* overrideVersionCode defaults to 0, if a source changes their source override code or
* a previous existing source suddenly needs source code overrides, overrideVersionCode
* should be increased.
* When a new source is added with overrides, overrideVersionCode should still be set to 0
*
* Note: source code overrides are located in "multisrc/overrides/src/<themeName>/<sourceName>"
*/
abstract val overrideVersionCode: Int
data class SingleLang(
override val name: String,
override val baseUrl: String,
val lang: String,
override val isNsfw: Boolean = false,
override val className: String = name.replace(" ", ""),
override val pkgName: String = className.lowercase(Locale.ENGLISH),
override val sourceName: String = name,
override val overrideVersionCode: Int = 0,
) : ThemeSourceData()
data class MultiLang(
override val name: String,
override val baseUrl: String,
val langs: List<String>,
override val isNsfw: Boolean = false,
override val className: String = name.replace(" ", "") + "Factory",
override val pkgName: String = className.substringBefore("Factory").lowercase(Locale.ENGLISH),
override val sourceName: String = name,
override val overrideVersionCode: Int = 0,
) : ThemeSourceData()
}
/**
* This variable should be increased when the multisrc library changes in a way that prompts global extension upgrade
*/
const val multisrcLibraryVersion = 0