diff --git a/src/en/hachi/AndroidManifest.xml b/src/en/hachi/AndroidManifest.xml
new file mode 100644
index 000000000..c5fa43e9f
--- /dev/null
+++ b/src/en/hachi/AndroidManifest.xml
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/en/hachi/build.gradle b/src/en/hachi/build.gradle
new file mode 100644
index 000000000..5d60461e2
--- /dev/null
+++ b/src/en/hachi/build.gradle
@@ -0,0 +1,8 @@
+ext {
+ extName = 'Hachi'
+ extClass = '.Hachi'
+ extVersionCode = 1
+ isNsfw = true
+}
+
+apply from: "$rootDir/common.gradle"
diff --git a/src/en/hachi/res/mipmap-hdpi/ic_launcher.png b/src/en/hachi/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 000000000..f21175cbb
Binary files /dev/null and b/src/en/hachi/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/src/en/hachi/res/mipmap-mdpi/ic_launcher.png b/src/en/hachi/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 000000000..998f93fad
Binary files /dev/null and b/src/en/hachi/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/src/en/hachi/res/mipmap-xhdpi/ic_launcher.png b/src/en/hachi/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..8c83f7b0c
Binary files /dev/null and b/src/en/hachi/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/src/en/hachi/res/mipmap-xxhdpi/ic_launcher.png b/src/en/hachi/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..261ec505e
Binary files /dev/null and b/src/en/hachi/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/src/en/hachi/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/hachi/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 000000000..b9f36c669
Binary files /dev/null and b/src/en/hachi/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/Hachi.kt b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/Hachi.kt
new file mode 100644
index 000000000..e1c643e4e
--- /dev/null
+++ b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/Hachi.kt
@@ -0,0 +1,295 @@
+package eu.kanade.tachiyomi.extension.en.hachi
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.asObservableSuccess
+import eu.kanade.tachiyomi.source.model.FilterList
+import eu.kanade.tachiyomi.source.model.MangasPage
+import eu.kanade.tachiyomi.source.model.Page
+import eu.kanade.tachiyomi.source.model.SChapter
+import eu.kanade.tachiyomi.source.model.SManga
+import eu.kanade.tachiyomi.source.online.HttpSource
+import eu.kanade.tachiyomi.util.asJsoup
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.Interceptor
+import okhttp3.Request
+import okhttp3.Response
+import org.jsoup.nodes.Document
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+class Hachi : HttpSource() {
+ override val baseUrl = "https://hachi.moe"
+ override val lang = "en"
+ override val name = "Hachi"
+ override val supportsLatest = true
+
+ override val client = network.cloudflareClient.newBuilder()
+ .addInterceptor(::buildIdOutdatedInterceptor)
+ .build()
+
+ private fun buildIdOutdatedInterceptor(chain: Interceptor.Chain): Response {
+ val request = chain.request()
+ val response = chain.proceed(request)
+
+ if (
+ response.code == 404 &&
+ request.url.run {
+ host == baseUrl.removePrefix("https://") &&
+ pathSegments.getOrNull(0) == "_next" &&
+ pathSegments.getOrNull(1) == "data" &&
+ fragment != "DO_NOT_RETRY"
+ } &&
+ response.header("Content-Type")?.contains("text/html") != false
+ ) {
+ // The 404 page should have the current buildId
+ val document = response.asJsoup()
+ buildId = fetchBuildId(document)
+
+ // Redo request with new buildId
+ val url = request.url.newBuilder()
+ .setPathSegment(2, buildId)
+ .fragment("DO_NOT_RETRY")
+ .build()
+ val newRequest = request.newBuilder()
+ .url(url)
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+
+ return response
+ }
+
+ private val json: Json by injectLazy()
+ private val apiBaseUrl = "https://api.${baseUrl.toHttpUrl().host}"
+
+ // Popular
+ override fun popularMangaRequest(page: Int): Request {
+ val url = "$apiBaseUrl/article".toHttpUrl().newBuilder()
+ .addQueryParameter("page", (page - 1).toString())
+ .addQueryParameter("size", "28")
+ .addQueryParameter("property", "views")
+ .addQueryParameter("direction", "desc")
+ .addQueryParameter("query", "")
+ .addQueryParameter("fields", "title")
+ .addQueryParameter("tagMode", "false")
+ .addQueryParameter("type", "")
+ .addQueryParameter("status", "")
+ .addQueryParameter("chapterCount", "4")
+ .addQueryParameter("mature", "true")
+ .build()
+
+ return GET(url, headers)
+ }
+
+ override fun popularMangaParse(response: Response) = searchMangaParse(response)
+
+ // Latest
+ override fun latestUpdatesRequest(page: Int): Request {
+ val url = "$apiBaseUrl/article".toHttpUrl().newBuilder()
+ .addQueryParameter("page", (page - 1).toString())
+ .addQueryParameter("size", "28")
+ .addQueryParameter("property", "latestChapterDate")
+ .addQueryParameter("direction", "desc")
+ .addQueryParameter("query", "")
+ .addQueryParameter("fields", "title")
+ .addQueryParameter("tagMode", "false")
+ .addQueryParameter("type", "")
+ .addQueryParameter("status", "")
+ .addQueryParameter("chapterCount", "4")
+ .addQueryParameter("mature", "true")
+ .build()
+
+ return GET(url, headers)
+ }
+
+ override fun latestUpdatesParse(response: Response) = searchMangaParse(response)
+
+ // Search
+ override fun fetchSearchManga(
+ page: Int,
+ query: String,
+ filters: FilterList,
+ ): Observable {
+ if (!query.startsWith(SEARCH_PREFIX)) {
+ return super.fetchSearchManga(page, query, filters)
+ }
+
+ val request = mangaDetailsRequest(
+ SManga.create().apply {
+ url = "/article/${query.substringAfter(SEARCH_PREFIX)}"
+ },
+ )
+
+ return client.newCall(request).asObservableSuccess().map { response ->
+ val details = mangaDetailsParse(response)
+ MangasPage(listOf(details), false)
+ }
+ }
+
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
+ val url = "$apiBaseUrl/article".toHttpUrl().newBuilder().apply {
+ addQueryParameter("page", (page - 1).toString())
+ addQueryParameter("size", "28")
+ addQueryParameter("direction", "desc")
+ addQueryParameter("query", query)
+ addQueryParameter("fields", "title")
+ addQueryParameter("tagMode", "false")
+ addQueryParameter("type", "")
+ addQueryParameter("status", "")
+ addQueryParameter("chapterCount", "4")
+ addQueryParameter("mature", "true")
+ }.build()
+
+ return GET(url, headers)
+ }
+
+ override fun searchMangaParse(response: Response): MangasPage {
+ val dto = response.parseAs()
+ val mangas = dto.content.map { manga ->
+ SManga.create().apply {
+ setUrlWithoutDomain("/article/${manga.link}")
+ title = manga.title
+ artist = manga.artist
+ author = manga.author
+ description = manga.summary
+ genre = manga.tags.joinToString()
+ status = manga.status.parseStatus()
+ thumbnail_url = manga.coverImage
+ initialized = true
+ }
+ }
+
+ return MangasPage(mangas, !dto.last)
+ }
+
+ // Details
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val slug = patternMangaUrl.find(manga.url)?.groups?.get("slug")?.value
+ ?: throw Exception("Failed to find manga from URL")
+
+ val url = "$baseUrl/_next/data/$buildId/article/$slug.json".toHttpUrl().newBuilder()
+ .addQueryParameter("url", slug)
+ .build()
+
+ return GET(url, headers)
+ }
+
+ override fun getMangaUrl(manga: SManga): String {
+ return super.mangaDetailsRequest(manga).url.toString()
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ val dto = response.parseAs()
+
+ return SManga.create().apply {
+ url = "$baseUrl/article/${dto.pageProps.article.link}"
+ title = dto.pageProps.article.title
+ artist = dto.pageProps.article.artist
+ author = dto.pageProps.article.author
+ description = dto.pageProps.article.summary
+ genre = dto.pageProps.article.tags.joinToString()
+ status = dto.pageProps.article.status.parseStatus()
+ thumbnail_url = dto.pageProps.article.coverImage
+ initialized = true
+ }
+ }
+
+ // Chapters
+ override fun chapterListRequest(manga: SManga): Request {
+ return mangaDetailsRequest(manga)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val dto = response.parseAs()
+ val chapters = dto.pageProps.chapters.map { chapter ->
+ SChapter.create().apply {
+ val chapterNumber = chapter.chapterNumber.toString().removeSuffix(".0")
+ setUrlWithoutDomain("/article/${dto.pageProps.article.link}/chapter/$chapterNumber")
+ name = "Chapter $chapterNumber"
+
+ date_upload = runCatching {
+ dateFormat.parse(chapter.createdAt)?.time
+ }.getOrNull() ?: 0
+ chapter_number = chapter.chapterNumber
+ }
+ }
+
+ return chapters
+ }
+
+ // Pages
+ override fun pageListRequest(chapter: SChapter): Request {
+ val matchGroups = patternMangaUrl.find(chapter.url)!!.groups
+ val slug = matchGroups["slug"]!!.value
+ val number = matchGroups["number"]!!.value
+
+ val url = "$baseUrl/_next/data/$buildId/article/$slug/chapter/$number.json".toHttpUrl()
+ .newBuilder()
+ .addQueryParameter("url", slug)
+ .addQueryParameter("number", number)
+ .build()
+
+ return GET(url, headers)
+ }
+
+ override fun getChapterUrl(chapter: SChapter): String {
+ return super.pageListRequest(chapter).url.toString()
+ }
+
+ override fun pageListParse(response: Response): List {
+ val dto = response.parseAs()
+
+ return dto.pageProps.images.mapIndexed { i, img ->
+ Page(i, response.request.url.toString(), img)
+ }
+ }
+
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
+
+ // Other
+ private inline fun Response.parseAs(): T =
+ json.decodeFromString(body.string())
+
+ private fun String.parseStatus() = when (this.lowercase()) {
+ "ongoing" -> SManga.ONGOING
+ "completed" -> SManga.COMPLETED
+ "dropped" -> SManga.CANCELLED
+ else -> SManga.UNKNOWN
+ }
+
+ private fun fetchBuildId(document: Document? = null): String {
+ val realDocument = document
+ ?: client.newCall(GET(baseUrl, headers)).execute().use { it.asJsoup() }
+
+ val nextData = realDocument.selectFirst("script#__NEXT_DATA__")?.data()
+ ?: throw Exception("Failed to find __NEXT_DATA__")
+
+ val dto = json.decodeFromString(nextData)
+ return dto.buildId
+ }
+
+ private var buildId = ""
+ get() {
+ if (field == "") {
+ field = fetchBuildId()
+ }
+ return field
+ }
+
+ companion object {
+ private val dateFormat =
+ SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSSSS", Locale.ROOT).apply {
+ timeZone = TimeZone.getTimeZone("UTC")
+ }
+
+ private val patternMangaUrl =
+ """/article/(?[^/]+)(?:/chapter/(?[^/?]+))?""".toRegex()
+ const val SEARCH_PREFIX = "slug:"
+ }
+}
diff --git a/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiDto.kt b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiDto.kt
new file mode 100644
index 000000000..5ec348572
--- /dev/null
+++ b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiDto.kt
@@ -0,0 +1,249 @@
+package eu.kanade.tachiyomi.extension.en.hachi
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.descriptors.buildClassSerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+
+// Responses
+// @Serializable
+// class TagResponseDto(
+// val currentPage: Int,
+// val size: Int,
+// val tags: List,
+// val totalItems: Int,
+// val totalPages: Int,
+// ) {
+// @Serializable
+// class TagDto(
+// val articleCount: Int,
+// val id: Int,
+// val name: String,
+// )
+// }
+
+@Serializable
+class ArticleResponseDto(
+ val content: List,
+// val empty: Boolean,
+// val first: Boolean,
+ val last: Boolean,
+// val number: Int,
+// val numberOfElements: Int,
+// val pageable: PageableDto,
+// val size: Int,
+// val sort: SortDto,
+// val totalElements: Int,
+// val totalPages: Int,
+) {
+// @Serializable
+// class PageableDto(
+// val offset: Int,
+// val pageNumber: Int,
+// val pageSize: Int,
+// val paged: Boolean,
+// val sort: SortDto,
+// val unpaged: Boolean,
+// )
+}
+
+@Serializable
+class DetailsResponseDto(
+// @SerialName("__N_SSG")
+// val nSSG: Boolean,
+ val pageProps: PagePropsDto,
+) {
+ @Serializable
+ class PagePropsDto(
+ val article: ArticleDto,
+ val chapters: List,
+// val comicSeries: ComicSeriesDto,
+// val metaTags: List,
+// val moreLikeArticles: List,
+// val ratings: RatingsDto,
+// val stats: StatsDto,
+// val title: String,
+ ) {
+// @Serializable
+// class ComicSeriesDto(
+// val alternativeHeadline: String,
+// val artist: ArtistDto,
+// val author: AuthorDto,
+// @SerialName("@context")
+// val context: String,
+// val genre: String,
+// val name: String,
+// @SerialName("@type")
+// val type: String,
+// val url: String,
+// ) {
+// @Serializable
+// class ArtistDto(
+// val name: String,
+// @SerialName("@type")
+// val type: String,
+// )
+//
+// @Serializable
+// class AuthorDto(
+// val name: String,
+// @SerialName("@type")
+// val type: String,
+// )
+// }
+//
+// @Serializable
+// class RatingsDto(
+// val averageRating: Float,
+// val id: Int,
+// val ratingCounts: List,
+// val totalRatingCount: Int,
+// ) {
+// @Serializable
+// class RatingCountDto(
+// val count: Int,
+// val rating: Float,
+// )
+// }
+//
+// @Serializable
+// class StatsDto(
+// val allTimeViews: Int? = null,
+// val id: Int? = null,
+// val libraryEntryCounts: List? = null,
+// val monthlyViews: Int? = null,
+// val rank: Int? = null,
+// val totalLibraryEntries: Int? = null,
+// val weeklyViews: Int? = null,
+// ) {
+// @Serializable
+// class LibraryEntryCountDto(
+// val count: Int,
+// val status: String,
+// )
+// }
+ }
+}
+
+@Serializable
+class ChapterResponseDto(
+// @SerialName("__N_SSG")
+// val nSSG: Boolean,
+ val pageProps: PagePropsDto,
+) {
+ @Serializable
+ class PagePropsDto(
+// val chapter: ChapterFullDto,
+ val images: List,
+// val metaTags: List,
+ ) {
+// @Serializable
+// class ChapterFullDto(
+// val alternativeTitles: List,
+// val articleId: Int,
+// val articleType: String,
+// val articleUrl: String,
+// val chapterNumber: Float,
+// val createdAt: String,
+// val id: Int,
+// val imageLinks: List,
+// val mature: Boolean,
+// val nextChapterNumber: Float,
+// val previousChapterNumber: Float,
+// val title: String,
+// val totalChapters: Float,
+// )
+ }
+}
+
+// Common
+@Serializable
+class ChapterDto(
+ val chapterNumber: Float,
+ val createdAt: String,
+// val id: Int,
+// val views: Int,
+)
+
+// @Serializable
+// class MetaTagDto(
+// val content: String,
+// val `property`: String,
+// )
+
+// @Serializable
+// class AlternativeTitleDto(
+// val language: String,
+// val title: String,
+// )
+
+// @Serializable
+// class SortDto(
+// val empty: Boolean,
+// val sorted: Boolean,
+// val unsorted: Boolean,
+// )
+
+// @Serializable
+// class ExternalLinkDto(
+// val externalApp: ExternalAppDto,
+// val externalId: String,
+// val id: Int,
+// ) {
+// @Serializable
+// class ExternalAppDto(
+// val domain: String,
+// val id: Int,
+// val name: String,
+// val path: String,
+// )
+// }
+
+@Serializable
+class ArticleDto(
+// val alternativeTitles: List,
+ @Serializable(with = MissingFieldSerializer::class)
+ val artist: String?,
+ @Serializable(with = MissingFieldSerializer::class)
+ val author: String?,
+// val chapters: List,
+ val coverImage: String,
+// val createdAt: String,
+// val externalLinks: List,
+// val id: Int,
+// val imagePath: String? = null,
+ val link: String,
+// val maintainer: String? = null,
+// val mature: Boolean,
+// val originalLink: String? = null,
+// val poster: String? = null,
+// val rating: Float,
+ val status: String,
+ val summary: String,
+ val tags: List,
+ val title: String,
+// val totalChapters: Float,
+// val type: String,
+// val updatedAt: String,
+// val views: Int,
+)
+
+// Partial
+@Serializable
+class NextDataDto(
+ val buildId: String,
+)
+
+// Serializers
+object MissingFieldSerializer : KSerializer {
+ override val descriptor = buildClassSerialDescriptor("MissingField")
+
+ override fun deserialize(decoder: Decoder): String? {
+ return decoder.decodeString().takeIf { it != "N/A" }
+ }
+
+ override fun serialize(encoder: Encoder, value: String?) {
+ encoder.encodeString(value ?: "N/A")
+ }
+}
diff --git a/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiUrlActivity.kt b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiUrlActivity.kt
new file mode 100644
index 000000000..77549d451
--- /dev/null
+++ b/src/en/hachi/src/eu/kanade/tachiyomi/extension/en/hachi/HachiUrlActivity.kt
@@ -0,0 +1,36 @@
+package eu.kanade.tachiyomi.extension.en.hachi
+
+import android.app.Activity
+import android.content.ActivityNotFoundException
+import android.content.Intent
+import android.os.Bundle
+import android.util.Log
+import kotlin.system.exitProcess
+
+class HachiUrlActivity : Activity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val pathSegments = intent?.data?.pathSegments
+
+ if (pathSegments != null && pathSegments.size > 1) {
+ val slug = pathSegments[1]
+
+ val mainIntent = Intent().apply {
+ action = "eu.kanade.tachiyomi.SEARCH"
+ putExtra("query", "${Hachi.SEARCH_PREFIX}$slug")
+ putExtra("filter", packageName)
+ }
+
+ try {
+ startActivity(mainIntent)
+ } catch (e: ActivityNotFoundException) {
+ Log.e("HachiUrlActivity", e.toString())
+ }
+ } else {
+ Log.e("HachiUrlActivity", "could not parse uri from intent $intent")
+ }
+
+ finish()
+ exitProcess(0)
+ }
+}