From 35e5de9260ef914941d0f0cca3a7d208dda56535 Mon Sep 17 00:00:00 2001 From: Amnesia Date: Sun, 10 May 2026 23:48:04 -0400 Subject: [PATCH 01/13] Rewrite Sakuhentai: use VoeExtractor, add filters, add deep links, remove gallery support --- src/en/sakuhentai/AndroidManifest.xml | 22 ++ src/en/sakuhentai/build.gradle | 13 + .../res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 70 bytes .../res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 70 bytes .../res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 70 bytes .../res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 70 bytes .../res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 70 bytes .../en/sakuhentai/Sakuhentai.kt | 306 ++++++++++++++++++ .../en/sakuhentai/SakuhentaiFilters.kt | 52 +++ .../en/sakuhentai/SakuhentaiUrlActivity.kt | 30 ++ 10 files changed, 423 insertions(+) create mode 100644 src/en/sakuhentai/AndroidManifest.xml create mode 100644 src/en/sakuhentai/build.gradle create mode 100644 src/en/sakuhentai/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/en/sakuhentai/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/en/sakuhentai/src/eu/kanade/tachiyomi/animeextension/en/sakuhentai/Sakuhentai.kt create mode 100644 src/en/sakuhentai/src/eu/kanade/tachiyomi/animeextension/en/sakuhentai/SakuhentaiFilters.kt create mode 100644 src/en/sakuhentai/src/eu/kanade/tachiyomi/animeextension/en/sakuhentai/SakuhentaiUrlActivity.kt diff --git a/src/en/sakuhentai/AndroidManifest.xml b/src/en/sakuhentai/AndroidManifest.xml new file mode 100644 index 000000000..a6ab02aba --- /dev/null +++ b/src/en/sakuhentai/AndroidManifest.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + diff --git a/src/en/sakuhentai/build.gradle b/src/en/sakuhentai/build.gradle new file mode 100644 index 000000000..c96625529 --- /dev/null +++ b/src/en/sakuhentai/build.gradle @@ -0,0 +1,13 @@ +ext { + extName = 'Sakuhentai' + extClass = '.Sakuhentai' + extVersionCode = 2 + isNsfw = true +} + +dependencies { + implementation(project(':lib:voe-extractor')) + implementation(project(':lib:playlist-utils')) +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/sakuhentai/res/mipmap-hdpi/ic_launcher.png b/src/en/sakuhentai/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..08cd6f2bfd1b53ec5a4db72bed55f40907e8bdfa GIT binary patch literal 70 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1|;Q0k92}1TpU9xZY8JuI3K{zz}&{z5M@%E Q4U}N;boFyt=akR{0J().getSharedPreferences("source_$id", 0x0000) + } + + private val voeExtractor by lazy { VoeExtractor(client, headers) } + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + .add("Referer", "$baseUrl/") + .add("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .add("Accept-Language", "en-US,en;q=0.5") + + // ============================== Popular ============================== + + override fun popularAnimeRequest(page: Int): Request { + return GET("$baseUrl/hentai-animation-online/page/$page/", headers) + } + + override fun popularAnimeParse(response: Response): AnimesPage { + val document = response.asJsoup() + val animeList = document.select("article").mapNotNull { it.toSAnime() } + return AnimesPage(animeList, hasNextPage(document)) + } + + // ============================== Latest ============================== + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/page/$page/", headers) + } + + override fun latestUpdatesParse(response: Response): AnimesPage { + val document = response.asJsoup() + val animeList = document.select("article").mapNotNull { it.toSAnime() } + return AnimesPage(animeList, hasNextPage(document)) + } + + // ============================== Search ============================== + + override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request { + val params = SakuhentaiFilters.getSearchParameters(filters) + + // If a series filter is selected, use that path + val categoryPath = when { + params.series.isNotBlank() -> "/${params.series}/" + else -> null + } + + return if (categoryPath != null) { + GET("$baseUrl${categoryPath}page/$page/", headers) + } else if (query.isNotBlank()) { + if (page == 1) { + GET("$baseUrl/?s=${java.net.URLEncoder.encode(query, "UTF-8")}", headers) + } else { + GET("$baseUrl/page/$page/?s=${java.net.URLEncoder.encode(query, "UTF-8")}", headers) + } + } else { + GET("$baseUrl/hentai-animation-online/page/$page/", headers) + } + } + + override fun searchAnimeParse(response: Response): AnimesPage { + val document = response.asJsoup() + val animeList = document.select("article").mapNotNull { it.toSAnime() } + return AnimesPage(animeList, hasNextPage(document)) + } + + // Handle prefix search from URL intent + override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage { + if (query.startsWith(PREFIX_SEARCH)) { + val slug = query.removePrefix(PREFIX_SEARCH) + return client.newCall(GET("$baseUrl/$slug/", headers)) + .awaitSuccess() + .use { searchAnimeByIdParse(it) } + } + return super.getSearchAnime(page, query, filters) + } + + private fun searchAnimeByIdParse(response: Response): AnimesPage { + val anime = animeDetailsParse(response) + return AnimesPage(listOf(anime), false) + } + + override fun getFilterList(): AnimeFilterList = SakuhentaiFilters.FILTER_LIST + + // ============================== Details ============================== + + override fun animeDetailsParse(response: Response): SAnime { + val document = response.asJsoup() + + return SAnime.create().apply { + title = document.selectFirst("h1.entry-title")?.text()?.trim() ?: "Unknown" + thumbnail_url = document.selectFirst("img.wp-post-image")?.attr("abs:src") + ?: document.selectFirst("img.wp-post-image")?.attr("data-src") + description = document.selectFirst("p.saku")?.text()?.trim() + status = SAnime.COMPLETED + + // Extract genre/series from breadcrumbs + val breadcrumbs = document.select(".breadcrumb a") + val genres = breadcrumbs.mapNotNull { a -> + val href = a.attr("href") + val text = a.text().trim() + // Skip the "Home" breadcrumb + if (text.equals("Home", ignoreCase = true) || href == baseUrl || href == "$baseUrl/") { + null + } else { + text + } + }.filter { it.isNotBlank() } + + genre = genres.joinToString(", ").ifBlank { null } + + // Extract artist from description format "by ArtistName" + artist = description?.let { desc -> + Regex("""by\s+(\S+)""").find(desc)?.groupValues?.get(1) + } + + initialized = true + } + } + + // ============================== Episodes ============================== + + override fun episodeListParse(response: Response): List { + return listOf( + SEpisode.create().apply { + name = "Episode 1" + episode_number = 1f + url = response.request.url.toString() + }, + ) + } + + // ============================== Video Sources ============================== + + override fun videoListParse(response: Response): List