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..435c2ed2f
--- /dev/null
+++ b/src/en/sakuhentai/build.gradle
@@ -0,0 +1,12 @@
+ext {
+ extName = 'Sakuhentai'
+ extClass = '.Sakuhentai'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
+
+dependencies {
+ implementation(project(':lib:playlist-utils'))
+}
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 000000000..f72b8ca90
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/sakuhentai/res/mipmap-hdpi/ic_launcher_round.png b/src/en/sakuhentai/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 000000000..d84b782c9
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/src/en/sakuhentai/res/mipmap-mdpi/ic_launcher.png b/src/en/sakuhentai/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..e98cc8029
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/sakuhentai/res/mipmap-mdpi/ic_launcher_round.png b/src/en/sakuhentai/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 000000000..b875f2ca7
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher.png b/src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..faddc8767
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher_round.png b/src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..4bbf72326
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher.png b/src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..d7c390efe
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher_round.png b/src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..50eb22211
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..395e050fb
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher_round.png b/src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 000000000..04351cdee
Binary files /dev/null and b/src/en/sakuhentai/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/src/en/sakuhentai/src/eu/kanade/tachiyomi/animeextension/en/sakuhentai/Sakuhentai.kt b/src/en/sakuhentai/src/eu/kanade/tachiyomi/animeextension/en/sakuhentai/Sakuhentai.kt
new file mode 100644
index 000000000..ad4d734f4
--- /dev/null
+++ b/src/en/sakuhentai/src/eu/kanade/tachiyomi/animeextension/en/sakuhentai/Sakuhentai.kt
@@ -0,0 +1,363 @@
+package eu.kanade.tachiyomi.animeextension.en.sakuhentai
+
+import android.app.Application
+import android.content.SharedPreferences
+import android.util.Log
+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.Video
+import eu.kanade.tachiyomi.animesource.online.AnimeHttpSource
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.awaitSuccess
+import eu.kanade.tachiyomi.util.asJsoup
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Element
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
+import java.text.SimpleDateFormat
+import java.util.Locale
+
+class Sakuhentai : ConfigurableAnimeSource, AnimeHttpSource() {
+
+ override val name = "Sakuhentai"
+ override val baseUrl = "https://www.sakuhentai.net"
+ override val lang = "en"
+ override val supportsLatest = true
+
+ override val client: OkHttpClient = network.cloudflareClient
+
+ private val preferences: SharedPreferences by lazy {
+ Injekt.get().getSharedPreferences("source_$id", 0x0000)
+ }
+
+ private val extractor by lazy { SakuhentaiExtractor(client, headers) }
+
+ private val authorRegex = Regex("""\s+by\s+(\S.+)$""", RegexOption.IGNORE_CASE)
+
+ // ============================== Popular ===============================
+
+ override fun popularAnimeRequest(page: Int): Request =
+ GET("$baseUrl/hentai-animation-online/page/$page/", headers)
+
+ override fun popularAnimeParse(response: Response): AnimesPage {
+ val document = response.asJsoup()
+ val animeList = mutableListOf()
+ document.select("article.category-hentai-animation-online").forEach { article ->
+ try {
+ animeList.add(article.toSAnime())
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse popular anime entry: ${e.message}")
+ }
+ }
+ val hasNextPage = document.selectFirst(".page-numbers a.next") != null
+ return AnimesPage(animeList, hasNextPage)
+ }
+
+ // =============================== Latest ===============================
+
+ override fun latestUpdatesRequest(page: Int): Request =
+ GET("$baseUrl/page/$page/", headers)
+
+ override fun latestUpdatesParse(response: Response): AnimesPage {
+ val document = response.asJsoup()
+ val animeList = mutableListOf()
+ val recposts = document.select(".recpost")
+ if (recposts.isNotEmpty()) {
+ recposts.forEach { recpost ->
+ try {
+ val title = recpost.selectFirst(".recpost-title a")?.text() ?: ""
+ if (!title.contains("Gallery", ignoreCase = true)) {
+ animeList.add(recpost.recpostToSAnime())
+ }
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse latest recpost entry: ${e.message}")
+ }
+ }
+ } else {
+ document.select("article.category-hentai-animation-online").forEach { article ->
+ try {
+ animeList.add(article.toSAnime())
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse latest anime entry: ${e.message}")
+ }
+ }
+ }
+ return AnimesPage(animeList, false)
+ }
+
+ // =============================== Search ===============================
+
+ override fun searchAnimeRequest(page: Int, query: String, filters: AnimeFilterList): Request {
+ val params = SakuhentaiFilters.getSearchParameters(filters)
+ return when {
+ query.isNotBlank() -> {
+ val encoded = java.net.URLEncoder.encode(query, "UTF-8")
+ if (page == 1) {
+ GET("$baseUrl/?s=$encoded", headers)
+ } else {
+ GET("$baseUrl/page/$page/?s=$encoded", headers)
+ }
+ }
+ params.series.isNotBlank() -> GET("$baseUrl/${params.series}/page/$page/", headers)
+ else -> popularAnimeRequest(page)
+ }
+ }
+
+ override fun searchAnimeParse(response: Response): AnimesPage {
+ val document = response.asJsoup()
+ val animeList = mutableListOf()
+
+ // Primary: animation category articles
+ document.select("article.category-hentai-animation-online").forEach { article ->
+ try {
+ animeList.add(article.toSAnime())
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse search animation entry: ${e.message}")
+ }
+ }
+
+ // Secondary: gallery articles with "Animation" in title (common on series pages)
+ document.select("article.category-hentai-gallery").forEach { article ->
+ try {
+ val title = article.selectFirst(".entry-title a")?.text() ?: ""
+ if (title.contains("Animation", ignoreCase = true)) {
+ animeList.add(article.toSAnime())
+ }
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse search gallery entry: ${e.message}")
+ }
+ }
+
+ // Tertiary: uncategorized articles with "Animation" in title
+ document.select("article").forEach { article ->
+ try {
+ if (
+ !article.hasClass("category-hentai-animation-online") &&
+ !article.hasClass("category-hentai-gallery")
+ ) {
+ val title = article.selectFirst(".entry-title a")?.text() ?: ""
+ if (title.contains("Animation", ignoreCase = true)) {
+ animeList.add(article.toSAnime())
+ }
+ }
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse search uncategorized entry: ${e.message}")
+ }
+ }
+
+ val hasNextPage = document.selectFirst(".page-numbers a.next") != null
+ return AnimesPage(animeList.distinctBy { it.url }, hasNextPage)
+ }
+
+ override suspend fun getSearchAnime(page: Int, query: String, filters: AnimeFilterList): AnimesPage {
+ return if (query.startsWith(PREFIX_SEARCH)) {
+ val id = query.removePrefix(PREFIX_SEARCH)
+ client.newCall(GET("$baseUrl/$id", headers))
+ .awaitSuccess()
+ .use { searchAnimeByIdParse(it) }
+ } else {
+ super.getSearchAnime(page, query, filters)
+ }
+ }
+
+ private fun searchAnimeByIdParse(response: Response): AnimesPage {
+ val anime = animeDetailsParse(response).apply {
+ setUrlWithoutDomain(response.request.url.toString().substringAfter(baseUrl).ifEmpty { "/" })
+ initialized = true
+ }
+ return AnimesPage(listOf(anime), false)
+ }
+
+ override fun getFilterList(): AnimeFilterList = SakuhentaiFilters.FILTER_LIST
+
+ // =========================== Anime Details ===========================
+
+ override fun animeDetailsParse(response: Response): SAnime {
+ val document = response.asJsoup()
+ return try {
+ val thumbnailUrl = document.selectFirst("meta[property=og:image]")?.attr("content")
+ ?.ifBlank { document.selectFirst("img.wp-post-image")?.absUrl("src") }
+ ?: ""
+ val descriptionText = document.selectFirst("p.saku")?.text()?.trim() ?: ""
+
+ val genreList = mutableListOf()
+ document.select(".breadcrumb a").forEach { breadcrumb ->
+ val text = breadcrumb.text().trim()
+ if (text.isNotBlank() && text != "Sakuhentai") {
+ genreList.add(text)
+ }
+ }
+
+ val rawTitle = document.selectFirst("h1.entry-title")?.text()?.trim() ?: "Unknown"
+ val authorMatch = authorRegex.find(rawTitle)
+ val cleanTitle = authorMatch?.groupValues?.get(1)?.let { rawTitle.removeSuffix(" by $it") }
+ ?: rawTitle
+ val authorName = authorMatch?.groupValues?.get(1) ?: ""
+
+ SAnime.create().apply {
+ setUrlWithoutDomain(response.request.url.toString().substringAfter(baseUrl).ifEmpty { "/" })
+ title = cleanTitle
+ author = authorName
+ artist = authorName
+ thumbnail_url = thumbnailUrl
+ description = descriptionText
+ genre = genreList.joinToString(", ")
+ status = SAnime.COMPLETED
+ }
+ } catch (e: Exception) {
+ Log.w("Sakuhentai", "Failed to parse anime details: ${e.message}")
+ SAnime.create().apply {
+ setUrlWithoutDomain(response.request.url.toString().substringAfter(baseUrl).ifEmpty { "/" })
+ title = "Error loading details"
+ status = SAnime.UNKNOWN
+ }
+ }
+ }
+
+ override fun animeDetailsRequest(anime: SAnime): Request {
+ val url = anime.url
+ return GET(if (url.startsWith("http")) url else baseUrl + url, headers)
+ }
+
+ // ============================== Episodes ==============================
+
+ override fun episodeListParse(response: Response): List {
+ val document = response.asJsoup()
+ val episode = SEpisode.create().apply {
+ setUrlWithoutDomain(response.request.url.toString().substringAfter(baseUrl).ifEmpty { "/" })
+ name = "Episode 1"
+ episode_number = 1f
+ date_upload = parseDate(
+ document.selectFirst("time.entry-date")?.attr("datetime")
+ ?: document.selectFirst(".entry-meta time")?.attr("datetime")
+ ?: document.selectFirst("[datetime]")?.attr("datetime"),
+ )
+ }
+ return listOf(episode)
+ }
+
+ private fun parseDate(dateStr: String?): Long {
+ if (dateStr.isNullOrBlank()) return 0L
+ return try {
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ", Locale.ENGLISH).parse(dateStr)?.time ?: 0L
+ } catch (_: Exception) {
+ try {
+ SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH).parse(dateStr)?.time ?: 0L
+ } catch (_: Exception) { 0L }
+ }
+ }
+
+ // =============================== Videos ==============================
+
+ override fun videoListRequest(episode: SEpisode): Request {
+ val url = episode.url
+ return GET(if (url.startsWith("http")) url else baseUrl + url, headers)
+ }
+
+ override fun videoListParse(response: Response): List