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,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".en.hstream.HstreamUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hstream.moe"
android:pathPattern="/hentai/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -0,0 +1,8 @@
ext {
extName = 'Hstream'
extClass = '.Hstream'
extVersionCode = 8
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,263 @@
package eu.kanade.tachiyomi.animeextension.en.hstream
import android.app.Application
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.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.util.asJsoup
import eu.kanade.tachiyomi.util.parseAs
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
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
import java.net.URLDecoder
import java.text.SimpleDateFormat
import java.util.Locale
class Hstream : ConfigurableAnimeSource, ParsedAnimeHttpSource() {
override val name = "Hstream"
override val baseUrl = "https://hstream.moe"
override val lang = "en"
override val supportsLatest = true
private val json: Json by injectLazy()
// URLs from the old extension are invalid now, so we're bumping this to
// make aniyomi interpret it as a new source, forcing old users to migrate.
override val versionId = 2
private val preferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
// ============================== Popular ===============================
override fun popularAnimeRequest(page: Int) = GET("$baseUrl/search?order=view-count&page=$page")
override fun popularAnimeSelector() = "div.items-center div.w-full > a"
override fun popularAnimeFromElement(element: Element) = SAnime.create().apply {
setUrlWithoutDomain(element.attr("href"))
title = element.selectFirst("img")!!.attr("alt")
val episode = url.substringAfterLast("-").substringBefore("/")
thumbnail_url = "$baseUrl/images${url.substringBeforeLast("-")}/cover-ep-$episode.webp"
}
override fun popularAnimeNextPageSelector() = "span[aria-current] + a"
// =============================== Latest ===============================
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/search?order=recently-uploaded&page=$page")
override fun latestUpdatesSelector() = popularAnimeSelector()
override fun latestUpdatesFromElement(element: Element) = popularAnimeFromElement(element)
override fun latestUpdatesNextPageSelector() = popularAnimeNextPageSelector()
// =============================== Search ===============================
override fun getFilterList() = HstreamFilters.FILTER_LIST
override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
return if (query.startsWith(PREFIX_SEARCH)) { // URL intent handler
val id = query.removePrefix(PREFIX_SEARCH)
client.newCall(GET("$baseUrl/hentai/$id"))
.awaitSuccess()
.use(::searchAnimeByIdParse)
} else {
super.getSearchAnime(page, query, filters)
}
}
private fun searchAnimeByIdParse(response: Response): AnimesPage {
val details = animeDetailsParse(response.asJsoup())
return AnimesPage(listOf(details), false)
}
override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
val params = HstreamFilters.getSearchParameters(filters)
val url = "$baseUrl/search".toHttpUrl().newBuilder().apply {
if (query.isNotBlank()) addQueryParameter("s", query)
addQueryParameter("page", page.toString())
addQueryParameter("order", params.order)
params.genres.forEachIndexed { index, genre -> addQueryParameter("tags[$index]", genre) }
params.blacklisted.forEach { addQueryParameter("blacklist[]", it) }
params.studios.forEach { addQueryParameter("studios[]", it) }
}.build()
return GET(url.toString())
}
override fun searchAnimeSelector() = popularAnimeSelector()
override fun searchAnimeFromElement(element: Element) = popularAnimeFromElement(element)
override fun searchAnimeNextPageSelector() = popularAnimeNextPageSelector()
// =========================== Anime Details ============================
override fun animeDetailsParse(document: Document) = SAnime.create().apply {
status = SAnime.COMPLETED
val floatleft = document.selectFirst("div.relative > div.justify-between > div")!!
title = floatleft.selectFirst("div > h1")!!.text()
artist = floatleft.select("div > a:nth-of-type(3)").text()
thumbnail_url = document.selectFirst("div.float-left > img.object-cover")?.absUrl("src")
genre = document.select("ul.list-none > li > a").eachText().joinToString()
description = document.selectFirst("div.relative > p.leading-tight")?.text()
}
// ============================== Episodes ==============================
override fun episodeListParse(response: Response): List<SEpisode> {
val doc = response.asJsoup()
val episode = SEpisode.create().apply {
date_upload = doc.selectFirst("a:has(i.fa-upload)")?.ownText().toDate()
setUrlWithoutDomain(doc.location())
val num = url.substringAfterLast("-").substringBefore("/")
episode_number = num.toFloatOrNull() ?: 1F
name = "Episode $num"
}
return listOf(episode)
}
override fun episodeListSelector(): String {
throw UnsupportedOperationException()
}
override fun episodeFromElement(element: Element): SEpisode {
throw UnsupportedOperationException()
}
// ============================ Video Links =============================
override fun videoListParse(response: Response): List<Video> {
val doc = response.asJsoup()
val token = client.cookieJar.loadForRequest(response.request.url)
.first { it.name.equals("XSRF-TOKEN") }
.value
val episodeId = doc.selectFirst("input#e_id")!!.attr("value")
val newHeaders = headersBuilder().apply {
set("Referer", doc.location())
set("Origin", baseUrl)
set("X-Requested-With", "XMLHttpRequest")
set("X-XSRF-TOKEN", URLDecoder.decode(token, "utf-8"))
}.build()
val body = """{"episode_id": "$episodeId"}""".toRequestBody("application/json".toMediaType())
val data = client.newCall(POST("$baseUrl/player/api", newHeaders, body)).execute()
.parseAs<PlayerApiResponse>()
val urlBase = data.stream_domains.random() + "/" + data.stream_url
val subtitleList = listOf(Track("$urlBase/eng.ass", "English"))
val resolutions = listOfNotNull("720", "1080", if (data.resolution == "4k") "2160" else null)
return resolutions.map { resolution ->
val url = urlBase + getVideoUrlPath(data.legacy != 0, resolution)
Video(url, "${resolution}p", url, subtitleTracks = subtitleList)
}
}
private fun getVideoUrlPath(isLegacy: Boolean, resolution: String): String {
return if (isLegacy) {
if (resolution.equals("720")) {
"/x264.720p.mp4"
} else {
"/av1.$resolution.webm"
}
} else {
"/$resolution/manifest.mpd"
}
}
@Serializable
data class PlayerApiResponse(
val legacy: Int = 0,
val resolution: String = "4k",
val stream_url: String,
val stream_domains: List<String>,
)
override fun videoListSelector(): String {
throw UnsupportedOperationException()
}
override fun videoFromElement(element: Element): Video {
throw UnsupportedOperationException()
}
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)
}
// ============================= Utilities ==============================
private fun String?.toDate(): Long {
return runCatching { DATE_FORMATTER.parse(orEmpty().trim(' ', '|'))?.time }
.getOrNull() ?: 0L
}
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()
}
companion object {
private val DATE_FORMATTER by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
const val PREFIX_SEARCH = "id:"
private const val PREF_QUALITY_KEY = "pref_quality_key"
private const val PREF_QUALITY_TITLE = "Preferred quality"
private const val PREF_QUALITY_DEFAULT = "720p"
private val PREF_QUALITY_ENTRIES = arrayOf("720p (HD)", "1080p (FULLHD)", "2160p (4K)")
private val PREF_QUALITY_VALUES = arrayOf("720p", "1080p", "2160p")
}
}

View file

@ -0,0 +1,212 @@
package eu.kanade.tachiyomi.animeextension.en.hstream
import eu.kanade.tachiyomi.animesource.model.AnimeFilter
import eu.kanade.tachiyomi.animesource.model.AnimeFilter.TriState
import eu.kanade.tachiyomi.animesource.model.AnimeFilterList
object HstreamFilters {
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, val pairs: Array<Pair<String, String>>) :
AnimeFilter.Group<AnimeFilter.CheckBox>(name, pairs.map { CheckBoxVal(it.first, false) })
private class CheckBoxVal(name: String, state: Boolean = false) : AnimeFilter.CheckBox(name, state)
open class TriStateFilterList(name: String, values: List<TriFilterVal>) : AnimeFilter.Group<TriState>(name, values)
class TriFilterVal(name: String) : TriState(name)
private inline fun <reified R> AnimeFilterList.asQueryPart(): String {
return (first { it is R } as QueryPartFilter).toQueryPart()
}
private inline fun <reified R> AnimeFilterList.parseCheckbox(
options: Array<Pair<String, String>>,
): List<String> {
return (first { it is R } as CheckBoxFilterList).state
.asSequence()
.filter { it.state }
.map { checkbox -> options.find { it.first == checkbox.name }!!.second }
.filter(String::isNotBlank)
.toList()
}
private inline fun <reified R> AnimeFilterList.parseTriFilter(
options: Array<Pair<String, String>>,
): List<List<String>> {
return (first { it is R } as TriStateFilterList).state
.filterNot { it.isIgnored() }
.map { filter -> filter.state to options.find { it.first == filter.name }!!.second }
.groupBy { it.first } // group by state
.let { dict ->
val included = dict.get(TriState.STATE_INCLUDE)?.map { it.second }.orEmpty()
val excluded = dict.get(TriState.STATE_EXCLUDE)?.map { it.second }.orEmpty()
listOf(included, excluded)
}
}
class GenresFilter : TriStateFilterList("Genres", HstreamFiltersData.GENRES.map { TriFilterVal(it.first) })
class StudiosFilter : CheckBoxFilterList("Studios", HstreamFiltersData.STUDIOS)
class OrderFilter : QueryPartFilter("Order by", HstreamFiltersData.ORDERS)
val FILTER_LIST get() = AnimeFilterList(
OrderFilter(),
GenresFilter(),
StudiosFilter(),
)
data class FilterSearchParams(
val genres: List<String> = emptyList(),
val blacklisted: List<String> = emptyList(),
val studios: List<String> = emptyList(),
val order: String = "view-count",
)
internal fun getSearchParameters(filters: AnimeFilterList): FilterSearchParams {
if (filters.isEmpty()) return FilterSearchParams()
val (added, blacklisted) = filters.parseTriFilter<GenresFilter>(HstreamFiltersData.GENRES)
return FilterSearchParams(
added,
blacklisted,
filters.parseCheckbox<StudiosFilter>(HstreamFiltersData.STUDIOS),
filters.asQueryPart<OrderFilter>(),
)
}
private object HstreamFiltersData {
val GENRES = arrayOf(
Pair("3D", "3d"),
Pair("4K", "4k"),
Pair("Ahegao", "ahegao"),
Pair("Anal", "anal"),
Pair("Bdsm", "bdsm"),
Pair("Big Boobs", "big-boobs"),
Pair("Blow Job", "blow-job"),
Pair("Bondage", "bondage"),
Pair("Boob Job", "boob-job"),
Pair("Censored", "censored"),
Pair("Comedy", "comedy"),
Pair("Cosplay", "cosplay"),
Pair("Creampie", "creampie"),
Pair("Dark Skin", "dark-skin"),
Pair("Elf", "elf"),
Pair("Facial", "facial"),
Pair("Fantasy", "fantasy"),
Pair("Filmed", "filmed"),
Pair("Foot Job", "foot-job"),
Pair("Futanari", "futanari"),
Pair("Gangbang", "gangbang"),
Pair("Glasses", "glasses"),
Pair("Hand Job", "hand-job"),
Pair("Harem", "harem"),
Pair("Horror", "horror"),
Pair("Incest", "incest"),
Pair("Inflation", "inflation"),
Pair("Lactation", "lactation"),
Pair("Loli", "loli"),
Pair("Maid", "maid"),
Pair("Masturbation", "masturbation"),
Pair("Milf", "milf"),
Pair("Mind Break", "mind-break"),
Pair("Mind Control", "mind-control"),
Pair("Monster", "monster"),
Pair("Nekomimi", "nekomimi"),
Pair("Ntr", "ntr"),
Pair("Nurse", "nurse"),
Pair("Orgy", "orgy"),
Pair("Pov", "pov"),
Pair("Pregnant", "pregnant"),
Pair("Public Sex", "public-sex"),
Pair("Rape", "rape"),
Pair("Reverse Rape", "reverse-rape"),
Pair("Rimjob", "rimjob"),
Pair("Scat", "scat"),
Pair("School Girl", "school-girl"),
Pair("Shota", "shota"),
Pair("Small Boobs", "small-boobs"),
Pair("Succubus", "succubus"),
Pair("Swim Suit", "swim-suit"),
Pair("Teacher", "teacher"),
Pair("Tentacle", "tentacle"),
Pair("Threesome", "threesome"),
Pair("Toys", "toys"),
Pair("Trap", "trap"),
Pair("Tsundere", "tsundere"),
Pair("Ugly Bastard", "ugly-bastard"),
Pair("Uncensored", "uncensored"),
Pair("Vanilla", "vanilla"),
Pair("Virgin", "virgin"),
Pair("X-Ray", "x-ray"),
Pair("Yuri", "yuri"),
)
val STUDIOS = arrayOf(
Pair("BOMB! CUTE! BOMB!", "bomb-cute-bomb"),
Pair("BreakBottle", "breakbottle"),
Pair("ChiChinoya", "chichinoya"),
Pair("ChuChu", "chuchu"),
Pair("Circle Tribute", "circle-tribute"),
Pair("Collaboration Works", "collaboration-works"),
Pair("Digital Works", "digital-works"),
Pair("Discovery", "discovery"),
Pair("Edge", "edge"),
Pair("Gold Bear", "gold-bear"),
Pair("Green Bunny", "green-bunny"),
Pair("Himajin Planning", "himajin-planning"),
Pair("King Bee", "king-bee"),
Pair("L.", "l"),
Pair("Lune Pictures", "lune-pictures"),
Pair("MS Pictures", "ms-pictures"),
Pair("Majin", "majin"),
Pair("Mary Jane", "mary-jane"),
Pair("Mediabank", "mediabank"),
Pair("Mousou Senka", "mousou-senka"),
Pair("Natural High", "natural-high"),
Pair("Nihikime no Dozeu", "nihikime-no-dozeu"),
Pair("Nur", "nur"),
Pair("Pashmina", "pashmina"),
Pair("Peak Hunt", "peak-hunt"),
Pair("Pink Pineapple", "pink-pineapple"),
Pair("Pixy Soft", "pixy-soft"),
Pair("Pixy", "pixy"),
Pair("PoRO", "poro"),
Pair("Queen Bee", "queen-bee"),
Pair("Rabbit Gate", "rabbit-gate"),
Pair("SELFISH", "selfish"),
Pair("Seven", "seven"),
Pair("Showten", "showten"),
Pair("Studio 1st", "studio-1st"),
Pair("Studio Eromatick", "studio-eromatick"),
Pair("Studio Fantasia", "studio-fantasia"),
Pair("Suiseisha", "suiseisha"),
Pair("Suzuki Mirano", "suzuki-mirano"),
Pair("T-Rex", "t-rex"),
Pair("Toranoana", "toranoana"),
Pair("Union Cho", "union-cho"),
Pair("Valkyria", "valkyria"),
Pair("White Bear", "white-bear"),
Pair("ZIZ", "ziz"),
)
val ORDERS = arrayOf(
Pair("View Count", "view-count"), // the only reason I'm not using a sort filter
Pair("A-Z", "az"),
Pair("Z-A", "za"),
Pair("Recently Uploaded", "recently-uploaded"),
Pair("Recently Released", "recently-released"),
Pair("Oldest Uploads", "oldest-uploads"),
Pair("Oldest Releases", "oldest-releases"),
)
}
}

View file

@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.animeextension.en.hstream
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://hstream.moe/hentai/<item> intents
* and redirects them to the main Aniyomi process.
*/
class HstreamUrlActivity : Activity() {
private val tag = javaClass.simpleName
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val item = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.ANIMESEARCH"
putExtra("query", "${Hstream.PREFIX_SEARCH}$item")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e(tag, e.toString())
}
} else {
Log.e(tag, "could not parse uri from intent $intent")
}
finish()
exitProcess(0)
}
}