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