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,8 @@
ext {
extName = 'Oppai Stream'
extClass = '.OppaiStream'
extVersionCode = 4
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

View file

@ -0,0 +1,350 @@
package eu.kanade.tachiyomi.animeextension.en.oppaistream
import android.app.Application
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import androidx.preference.SwitchPreferenceCompat
import eu.kanade.tachiyomi.animeextension.en.oppaistream.dto.AnilistResponseDto
import eu.kanade.tachiyomi.animesource.ConfigurableAnimeSource
import eu.kanade.tachiyomi.animesource.model.AnimeFilter.TriState.Companion.STATE_EXCLUDE
import eu.kanade.tachiyomi.animesource.model.AnimeFilter.TriState.Companion.STATE_INCLUDE
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.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.json.Json
import okhttp3.FormBody
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
class OppaiStream : ParsedAnimeHttpSource(), ConfigurableAnimeSource {
override val name = "Oppai Stream"
override val lang = "en"
override val baseUrl = "https://oppai.stream"
override val supportsLatest = true
override fun headersBuilder() = super.headersBuilder().add("Referer", baseUrl)
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
private val json: Json by injectLazy()
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/$SEARCH_PATH?order=views&page=$page&limit=$SEARCH_LIMIT")
override fun popularAnimeParse(response: Response) = searchAnimeParse(response)
override fun popularAnimeSelector() = searchAnimeSelector()
override fun popularAnimeFromElement(element: Element) = searchAnimeFromElement(element)
override fun popularAnimeNextPageSelector() = null
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/$SEARCH_PATH?order=uploaded&page=$page&limit=$SEARCH_LIMIT")
override fun latestUpdatesParse(response: Response) = searchAnimeParse(response)
override fun latestUpdatesSelector() = searchAnimeSelector()
override fun latestUpdatesFromElement(element: Element) = searchAnimeFromElement(element)
override fun latestUpdatesNextPageSelector() = null
// =============================== Search ===============================
override fun getFilterList() = FILTERS
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val url = "$baseUrl/$SEARCH_PATH".toHttpUrl().newBuilder().apply {
addQueryParameter("text", query.trim())
filters.forEach { filter ->
when (filter) {
is OrderByFilter -> {
addQueryParameter("order", filter.selectedValue())
}
is GenreListFilter -> {
val genresInclude = mutableListOf<String>()
val genresExclude = mutableListOf<String>()
filter.state.forEach { genreState ->
when (genreState.state) {
STATE_INCLUDE -> genresInclude.add(genreState.value)
STATE_EXCLUDE -> genresExclude.add(genreState.value)
}
}
addQueryParameter("genres", genresInclude.joinToString(","))
addQueryParameter("blacklist", genresExclude.joinToString(","))
}
is StudioListFilter -> {
addQueryParameter("studio", filter.state.filter { it.state }.joinToString(",") { it.value })
}
else -> {}
}
addQueryParameter("page", page.toString())
addQueryParameter("limit", SEARCH_LIMIT.toString())
}
}.build().toString()
return GET(url, headers)
}
override fun searchAnimeSelector() = "div.episode-shown > div > a"
override fun searchAnimeNextPageSelector() = null
override fun searchAnimeParse(response: Response): AnimesPage {
val document = response.asJsoup()
val elements = document.select(searchAnimeSelector())
val anime = elements.map(::searchAnimeFromElement).distinctBy { it.title }
val hasNextPage = elements.size >= SEARCH_LIMIT
return AnimesPage(anime, hasNextPage)
}
override fun searchAnimeFromElement(element: Element) = SAnime.create().apply {
thumbnail_url = element.selectFirst("img.cover-img-in")?.attr("abs:src")
title = element.selectFirst(".title-ep")!!.text().replace(TITLE_CLEANUP_REGEX, "")
setUrlWithoutDomain(
element.attr("href").replace(Regex("(?<=\\?e=)(.*?)(?=&f=)")) {
java.net.URLEncoder.encode(it.groupValues[1], "UTF-8")
},
)
}
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
// Fetch from from Anilist when "Anilist Cover" is selected in settings
val name = document.selectFirst("div.episode-info > h1")!!.text().substringBefore(" Ep ")
title = name
description = document.selectFirst("div.description")?.text()?.substringBeforeLast(" Watch ")
genre = document.select("div.tags a").joinToString { it.text() }
val studios = document.select("div.episode-info a.red").eachText()
artist = studios.joinToString()
val useAnilistCover = preferences.getBoolean(PREF_ANILIST_COVER_KEY, PREF_ANILIST_COVER_DEFAULT)
val thumbnailUrl = if (useAnilistCover) {
val newTitle = name.replace(Regex("[^a-zA-Z0-9\\s!.:\"]"), " ")
runCatching { fetchThumbnailUrlByTitle(newTitle) }.getOrNull()
} else {
null // Use default cover (episode preview)
}
// Match local studios with anilist studios to increase the accuracy of the poster
val matchedStudio = thumbnailUrl?.second?.find { it in studios }
thumbnail_url = matchedStudio?.let { thumbnailUrl.first }
?: document.selectFirst("video#episode")?.attr("poster")
}
// ============================== Episodes ==============================
override fun episodeListParse(response: Response): List<SEpisode> {
val doc = response.asJsoup()
return buildList {
doc.select(episodeListSelector())
.map(::episodeFromElement)
.let(::addAll)
add(
SEpisode.create().apply {
setUrlWithoutDomain(
doc.location().replace(Regex("(?<=\\?e=)(.*?)(?=&f=)")) {
java.net.URLEncoder.encode(it.groupValues[1], "UTF-8")
},
)
val num = doc.selectFirst("div.episode-info > h1")!!.text().substringAfter(" Ep ")
name = "Episode $num"
episode_number = num.toFloatOrNull() ?: 1F
scanlator = doc.selectFirst("div.episode-info a.red")?.text()
},
)
}.sortedByDescending { it.episode_number }
}
override fun episodeListSelector() = "div.more-same-eps > div > div > a"
override fun episodeFromElement(element: Element) = SEpisode.create().apply {
setUrlWithoutDomain(
element.attr("href").replace(Regex("(?<=\\?e=)(.*?)(?=&f=)")) {
java.net.URLEncoder.encode(it.groupValues[1], "UTF-8")
},
)
val num = element.selectFirst("font.ep")?.text() ?: "1"
name = "Episode $num"
episode_number = num.toFloatOrNull() ?: 1F
scanlator = element.selectFirst("h6 > a")?.text()
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val doc = response.asJsoup()
val script = doc.selectFirst("script:containsData(var availableres)")!!.data()
val subtitles = doc.select("track[kind=captions]").map {
Track(it.attr("src"), it.attr("label"))
}
return script.substringAfter("var availableres = {").substringBefore('}')
.split(',')
.map {
val (resolution, url) = it.replace("\"", "").replace("\\", "").split(':', limit = 2)
val fixedResolution = when (resolution) {
"4k" -> "2160p"
else -> "${resolution}p"
}
Video(url, fixedResolution, url, headers, subtitles)
}
}
override fun videoListSelector() = throw UnsupportedOperationException()
override fun videoFromElement(element: Element) = throw UnsupportedOperationException()
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()
}
override fun videoUrlParse(document: Document): String {
throw UnsupportedOperationException()
}
// ============================== Settings ==============================
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_QUALITY_KEY
title = PREF_QUALITY_TITLE
entries = PREF_QUALITY_ENTRIES
entryValues = PREF_QUALITY_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)
SwitchPreferenceCompat(screen.context).apply {
key = PREF_ANILIST_COVER_KEY
title = PREF_ANILIST_COVER_TITLE
summary = PREF_ANILIST_COVER_SUMMARY
setDefaultValue(PREF_ANILIST_COVER_DEFAULT)
setOnPreferenceChangeListener { _, newValue ->
val new = newValue as Boolean
preferences.edit().putBoolean(key, new).commit()
}
}.also(screen::addPreference)
ListPreference(screen.context).apply {
key = PREF_COVER_QUALITY_KEY
title = PREF_COVER_QUALITY_TITLE
entries = PREF_COVER_QUALITY_ENTRIES
entryValues = PREF_COVER_QUALITY_VALUES
setDefaultValue(PREF_COVER_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)
}
// ============================= Utilities ==============================
// Function to fetch thumbnail URL using AniList GraphQL API
// Only use in animeDetailsParse.
private fun fetchThumbnailUrlByTitle(title: String): Pair<String, List<String>>? {
val query = """
query {
Media(search: "$title", type: ANIME, isAdult: true) {
coverImage {
extraLarge
large
}
studios {
nodes {
name
}
}
}
}
""".trimIndent()
val requestBody = FormBody.Builder()
.add("query", query)
.build()
val request = POST("https://graphql.anilist.co", body = requestBody)
val response = client.newCall(request).execute()
return parseThumbnailUrlFromObject(response.parseAs<AnilistResponseDto>())
}
private fun parseThumbnailUrlFromObject(obj: AnilistResponseDto): Pair<String, List<String>>? {
val media = obj.data.media ?: return null
val coverURL = when (preferences.getString(PREF_COVER_QUALITY_KEY, PREF_COVER_QUALITY_DEFAULT)) {
"extraLarge" -> media.coverImage.extraLarge
else -> media.coverImage.large
}
val studiosList = media.studios.names
return Pair(coverURL, studiosList)
}
companion object {
private const val SEARCH_PATH = "actions/search.php"
private const val SEARCH_LIMIT = 36
private val TITLE_CLEANUP_REGEX = Regex("""\s+\d+$""")
private const val PREF_QUALITY_KEY = "preferred_quality"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_ENTRIES = arrayOf("2160p", "1080p", "720p")
private val PREF_QUALITY_VALUES = PREF_QUALITY_ENTRIES
private const val PREF_ANILIST_COVER_KEY = "preferred_anilist_cover"
private const val PREF_ANILIST_COVER_TITLE = "Use Anilist as cover source - Beta"
private const val PREF_ANILIST_COVER_DEFAULT = true
private const val PREF_ANILIST_COVER_SUMMARY = "This feature is experimental. " +
"It enables fetching covers from Anilist. If you see the default cover " +
"after switching to AniList cover, try clearing the cache in " +
"Settings > Advanced > Clear Anime Database > Oppai Steam. It only fetch Anilist covers in anime details page."
private const val PREF_COVER_QUALITY_KEY = "preferred_cover_quality"
private const val PREF_COVER_QUALITY_TITLE = "Preferred Anilist cover quality - Beta"
private const val PREF_COVER_QUALITY_DEFAULT = "large"
private val PREF_COVER_QUALITY_ENTRIES = arrayOf("Extra Large", "Large")
private val PREF_COVER_QUALITY_VALUES = arrayOf("extraLarge", "large")
}
}

View file

@ -0,0 +1,205 @@
package eu.kanade.tachiyomi.animeextension.en.oppaistream
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
open class SelectFilter(
displayName: String,
private val vals: Array<Pair<String, String>>,
) : AnimeFilter.Select<String>(
displayName,
vals.map { it.first }.toTypedArray(),
) {
fun selectedValue() = vals[state].second
}
class OrderByFilter : SelectFilter(
"Sort By",
arrayOf(
Pair("A-Z", "az"),
Pair("Z-A", "za"),
Pair("Recently Released", "recent"),
Pair("Oldest Releases", "old"),
Pair("Most Views", "views"),
Pair("Highest Rated", "rating"),
Pair("Recently Uploaded", "uploaded"),
Pair("Randomize", "random"),
),
)
class TriFilter(name: String, val value: String) : AnimeFilter.TriState(name)
private fun getGenreList(): List<TriFilter> = listOf(
TriFilter("4k", "4k"),
TriFilter("Ahegao", "ahegao"),
TriFilter("Anal", "anal"),
TriFilter("Armpit Masturbation", "armpitmasturbation"),
TriFilter("BDSM", "bdsm"),
TriFilter("Beach", "beach"),
TriFilter("Big Boobs", "bigboobs"),
TriFilter("Black Hair", "blackhair"),
TriFilter("Blonde Hair", "blondehair"),
TriFilter("BlowJob", "blowjob"),
TriFilter("Blue Hair", "bluehair"),
TriFilter("Bondage", "bondage"),
TriFilter("BoobJob", "boobjob"),
TriFilter("Brown Hair", "brownhair"),
TriFilter("Censored", "censored"),
TriFilter("Comedy", "comedy"),
TriFilter("Cosplay", "cosplay"),
TriFilter("Cowgirl", "cowgirl"),
TriFilter("Creampie", "creampie"),
TriFilter("Dark Skin", "darkskin"),
TriFilter("Demon", "demon"),
TriFilter("Doggy", "doggy"),
TriFilter("Dominant Girl", "dominantgirl"),
TriFilter("Double Penetration", "doublepenetration"),
TriFilter("Elf", "elf"),
TriFilter("Facial", "facial"),
TriFilter("Fantasy", "fantasy"),
TriFilter("Filmed", "filmed"),
TriFilter("FootJob", "footjob"),
TriFilter("Futanari", "futanari"),
TriFilter("Gangbang", "gangbang"),
TriFilter("Girls Only", "girlsonly"),
TriFilter("Glasses", "glasses"),
TriFilter("Green Hair", "greenhair"),
TriFilter("Gyaru", "gyaru"),
TriFilter("HD", "hd"),
TriFilter("HandJob", "handjob"),
TriFilter("Harem", "harem"),
TriFilter("Horror", "horror"),
TriFilter("Incest", "incest"),
TriFilter("Inflation", "inflation"),
TriFilter("Inverted Nipples", "invertednipples"),
TriFilter("Lactation", "lactation"),
TriFilter("Loli", "loli"),
TriFilter("Maid", "maid"),
TriFilter("Masturbation", "masturbation"),
TriFilter("Milf", "milf"),
TriFilter("Mind Break", "mindbreak"),
TriFilter("Mind Control", "mindcontrol"),
TriFilter("Missionary", "missionary"),
TriFilter("Monster", "monster"),
TriFilter("NTR", "ntr"),
TriFilter("Nekomimi", "nekomimi"),
TriFilter("Nurse", "nurse"),
TriFilter("Old", "old"),
TriFilter("Orgy", "orgy"),
TriFilter("POV", "pov"),
TriFilter("Pink Hair", "pinkhair"),
TriFilter("Plot", "plot"),
TriFilter("Pregnant", "pregnant"),
TriFilter("Public Sex", "publicsex"),
TriFilter("Purple Hair", "purplehair"),
TriFilter("Rape", "rape"),
TriFilter("Red Hair", "redhair"),
TriFilter("Reverse Gangbang", "reversegangbang"),
TriFilter("Reverse Rape", "reverserape"),
TriFilter("Rimjob", "rimjob"),
TriFilter("Scat", "scat"),
TriFilter("School Girl", "schoolgirl"),
TriFilter("Short Hair", "shorthair"),
TriFilter("Shota", "shota"),
TriFilter("Small Boobs", "smallboobs"),
TriFilter("Softcore", "softcore"),
TriFilter("Succubus", "succubus"),
TriFilter("Swimsuit", "swimsuit"),
TriFilter("Teacher", "teacher"),
TriFilter("Tentacle", "tentacle"),
TriFilter("Threesome", "threesome"),
TriFilter("Toys", "toys"),
TriFilter("Trap", "trap"),
TriFilter("Tripple Penetration", "tripplepenetration"),
TriFilter("Tsundere", "tsundere"),
TriFilter("Ugly Bastard", "uglybastard"),
TriFilter("Uncensored", "uncensored"),
TriFilter("Vampire", "vampire"),
TriFilter("Vanilla", "vanilla"),
TriFilter("Virgin", "virgin"),
TriFilter("Watersports", "watersports"),
TriFilter("White Hair", "whitehair"),
TriFilter("X-Ray", "x-ray"),
TriFilter("Yaoi", "yaoi"),
TriFilter("Yuri", "yuri"),
)
class GenreListFilter(genres: List<TriFilter>) : AnimeFilter.Group<TriFilter>("Genre", genres)
class CheckFilter(name: String, val value: String) : AnimeFilter.CheckBox(name)
private fun getStudioList(): List<CheckFilter> = listOf(
CheckFilter("44℃ Baidoku", "44℃ Baidoku"),
CheckFilter("AT-X", "AT-X"),
CheckFilter("AXsiZ", "AXsiZ"),
CheckFilter("Alice Soft", "Alice Soft"),
CheckFilter("Antechinus", "Antechinus"),
CheckFilter("An♥Tekinus", "An♥Tekinus"),
CheckFilter("BOOTLEG", "BOOTLEG"),
CheckFilter("BREAKBOTTLE", "BREAKBOTTLE"),
CheckFilter("Bomb! Cute! Bomb!", "Bomb! Cute! Bomb!"),
CheckFilter("Breakbottle", "Breakbottle"),
CheckFilter("Bunny Walker", "Bunny Walker"),
CheckFilter("ChuChu", "ChuChu"),
CheckFilter("Collaboration Works", "Collaboration Works"),
CheckFilter("Cotton Doll", "Cotton Doll"),
CheckFilter("Digital Works", "Digital Works"),
CheckFilter("Global Solutions", "Global Solutions"),
CheckFilter("HiLLS", "HiLLS"),
CheckFilter("Himajin Planning", "Himajin Planning"),
CheckFilter("JapanAnime", "JapanAnime"),
CheckFilter("Jumondou", "Jumondou"),
CheckFilter("Kitty Media", "Kitty Media"),
CheckFilter("Lune Pictures", "Lune Pictures"),
CheckFilter("MS Pictures", "MS Pictures"),
CheckFilter("Magic Bus", "Magic Bus"),
CheckFilter("Magin Label", "Magin Label"),
CheckFilter("Majin Petit", "Majin Petit"),
CheckFilter("Majin petit", "Majin petit"),
CheckFilter("Majin", "Majin"),
CheckFilter("Mary Jane", "Mary Jane"),
CheckFilter("Mediabank", "Mediabank"),
CheckFilter("Milky Animation Label", "Milky Animation Label"),
CheckFilter("Mirai Koujou", "Mirai Koujou"),
CheckFilter("NBCUniversal Entertainment Japan", "NBCUniversal Entertainment Japan"),
CheckFilter("Natural High", "Natural High"),
CheckFilter("NewGeneration", "NewGeneration"),
CheckFilter("Nippon Columbia", "Nippon Columbia"),
CheckFilter("Nur", "Nur"),
CheckFilter("Office Nobu", "Office Nobu"),
CheckFilter("Pashima", "Pashima"),
CheckFilter("Pashmina", "Pashmina"),
CheckFilter("Passione", "Passione"),
CheckFilter("Peak Hunt", "Peak Hunt"),
CheckFilter("Pink Pineapple", "Pink Pineapple"),
CheckFilter("PoRO petit", "PoRO petit"),
CheckFilter("PoRO", "PoRO"),
CheckFilter("Queen Bee", "Queen Bee"),
CheckFilter("Rabbit Gate", "Rabbit Gate"),
CheckFilter("Seven", "Seven"),
CheckFilter("Shion", "Shion"),
CheckFilter("Show-Ten", "Show-Ten"),
CheckFilter("Shueisha", "Shueisha"),
CheckFilter("Studio 1st", "Studio 1st"),
CheckFilter("Studio Gokumi", "Studio Gokumi"),
CheckFilter("Studio Houkiboshi", "Studio Houkiboshi"),
CheckFilter("Suzuki Mirano", "Suzuki Mirano"),
CheckFilter("T-Rex", "T-Rex"),
CheckFilter("TEATRO Nishi Tokyo Studio", "TEATRO Nishi Tokyo Studio"),
CheckFilter("TNK", "TNK"),
CheckFilter("Toranoana", "Toranoana"),
CheckFilter("WHITE BEAR", "WHITE BEAR"),
CheckFilter("Y.O.U.C", "Y.O.U.C"),
CheckFilter("YTV", "YTV"),
CheckFilter("Yomiuri TV Enterprise", "Yomiuri TV Enterprise"),
CheckFilter("ZIZ Entertainment", "ZIZ Entertainment"),
CheckFilter("erozuki", "erozuki"),
)
class StudioListFilter(studios: List<CheckFilter>) : AnimeFilter.Group<CheckFilter>("Studio", studios)
val FILTERS: AnimeFilterList get() = AnimeFilterList(
OrderByFilter(),
GenreListFilter(getGenreList()),
StudioListFilter(getStudioList()),
)

View file

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.animeextension.en.oppaistream.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AnilistResponseDto(val data: DataDto)
@Serializable
data class DataDto(@SerialName("Media") val media: MediaDto?)
@Serializable
data class MediaDto(val coverImage: CoverDto, val studios: StudiosDto)
@Serializable
data class CoverDto(val extraLarge: String, val large: String)
@Serializable
data class StudiosDto(val nodes: List<NodeDto>) {
@Serializable
data class NodeDto(val name: String)
val names by lazy { nodes.map { it.name } }
}