ANadpDsKkIB)?Ws~8Y@z^{vy3%hcaHTct}2&HJ=aB7fX&>=baP
z;=>Kasn_ZsAs$t$j!!dnn|%KKCEIe7KlSav=9tHicQ(<>+v$SGi*}hMKx=28EaFk=
zlOP2FUH1xL1X248?ePv!r=nbJYQJ<9efgFmIEZqevL92rZ9PLg$R>xye6U}EbJNr|LzrDJ!(8GP5
zY82ZgX6y3&n+#!zkSx2*BuY77O?y0SG;4wkKD{$%CTv-}HD#(@?0yjWx;vU$=W$S2
znb)F@xU!<;N4boOUfiKsT_&t!o@+sQLxTsm>N2`0SSYlwKz2Uhg;<+Y8`qAxT3t9U
z>i!f}DUU=D0dNpMR;$bcs_YOn44QJo`s!7spAX^dYO{N#pQj=B`g3k7+a(=dV+~1N
zzS!dOfTYI9I~2rR0(3?3a0&-aDLm;jQlmBxRX81neb{lj0zBv1kvIu7kLw2
zM`8ct2Dgnn39BlfMHso?G}2JjExRhL8msLXJ5}LZef8rUGP+Zbe8jb{{dU0tC%ma?
zX|n#Bb;r+^YsmSftZxhLho5-ztoLY!EldSdLfWc`cJ|=mj`LUtw~KiH5-f{Xya(rv
z8{zJxsM>-IV;?5;u$NXk4}-jPp6xqMY_Nw_Dzh>kEg&Y_ecgHRO-dh9Sc9B%$@1}g
zr4oHyQbH1vv_2Yale8^?Bb|BSKHJluKb(PkEPY68qwnK&@>F{Iajd(02YS{Sjh>y&
za=r5COCZ9^x8zgqav=E{HL&_FjQaR5BwRpWlvj|TeSa&Y;LNSF`G%DQd9c2oW>MO{
zyUMnG^})8rJ{n#L5AblRc8(FcYEBwzid$qhqpz`ex}C1rx|wWXo7J)nL-QBJUUFo
zU$)hT(3wAFM@ES~figNh-X#gLfMun&FKZP=tYh010haZI!`qa21MGDs;fnLO%FiK^
ze{aOD5MGks99Q{x&svU|rTxQKayS1tZ3OJ;Z}=aI_5C|brugKyhZb7zJD19_^>Uen
zH27W_(a>-O()=ttFRqQ7<=HZZs7NgQR-j2a*>)r*dvLWfMY6BdkzA6}v9$YyvPz&k
zHvWwt`;$G~p{w(-#Mccp3M4xrdka$dSk7%Eh0vYMrCY2`qPcp1_}tB20QyenrjYMj
z89fuoFD2<6d*@FSBy8lL{FZfSH|us3>d5>%D_uY>dAd^0B9`w&KgPNtCG8sX`v!|kVydOA1H7JjB
z)J3Ox=$L>w+d6MivyyPo$XlIoRBUw~0b7sHzC`F|4
zW|J!H{-M+!rGxh!;(9A?eoTsAM+dBJQ(>ax)MenJoNVz9n9Q~nwWy)Codq*t)s_Ko
zM=gJA91O3JMC9$3S>paBLM!gTLw#Ik5@`b##@Mf~|I0X-yJf-!uTOig#-+f-*Gs-#
zY2eH~IHcJ9iyvqa+;h|EbEAp>(p?;z#+9?CT{E*t-^(2$#^E>Haw*wzC9P$Y5q*;}
zYx7a|Pr_U3qSswoB5FTXshbll-?@x7`!3aao^~$kDKyrU)mYDCnPjjK{(ab%@~XH*
z<{klx^au7^I<05ECDxv3%3eki*Mo7k^)L7VY3&GY1|$9y)1JF`tUQc6L3hUkDpLZY
z{1+7HglCP)(Pw1zreW!ip{KXx|Um4J=_UQ-hJFkedEtUF#9zlwjK80!QR
z$P#4z-gdK`PgQ$cnK_!AQd6N_(_VnlMIl-g8HJ8SI|>M`&i5BktIn#TF>aU%C`FaB10A6!tzfK2?gA3OP)<#qNE2EDgtlC0B@=jt3u
z=NkRtxvsys7^6xx=CP2;Ftgr1Z1tunHx^3Nu@Qp%YVfp!Z@EOZt=5N@K`f`$;XNXC
zL7g?9fp&FL`j}N@^5bY1@)1_M&x(|n%{ruSjf))XQDBiT$bueV8^3wcs4!;X;6{Ls
zI+@R=VT*Y{u^9d(fFNUji`x9{(G5&S&{|?d%7Nr{^}BoDso-kae~uQD8yh`L)^CJH
z9}li{v!#>9#dgTFRAYUW8rx3y{kFqguIdA{9xLHLc$EsBJNGmIADf0A7*Nid{x@CJ
zaI6riNj(xub8OD~;BXvrMF^YuBY{_c9e~^NpMah>=f@56E-V!0n98DcNu6iVH*xzg
zPU|31awU7$o<2>G!c>bb&D&aga9#G#byj`yk;t~W
z2H8(xcdN)_dFr~L*t_dnr}KhI&yKwy^&<(aBfr+00nL*e<;qO5$mgH)8p~h8Oh|%j
zxH}`vR18JQrLTg*c
z{lO5Fachg)pH;bj&@hrN@?$n9eQwrlT9j3T!|Hue+gqab-7(qpGJ>B6PFGH*Ud
zUavVeFWqL~W)Snn`&(od`htc@@Ic@o@;Aq-@F#pgHC!FDd;<^Ic|P?o=MFY6)jGmO
zRD@q@lWt1%#`k=tsJo1#VWTgs(!(R-*o^b2*JKYdgNS%|veI}ne-I<`C*^4r@Tmnpm!?rse>)#nrg*{yw1?hH1{Gzf
zGW`~ApH90`Xwu1&Bx#3JFU#qI058$q^$fSyp;~6m)u47?xCUqkiMTJTw!OH(siT({
z_T}YOhAw0)CHz5!4>wtmq@bsJ=VFa&*I2wKqC)&RE%q^WOVSRT{<
z17b204`yOkbJ~66y%BgUG!?k;CUI}~$wETMOzDVvjHd!?ngf-`DY4kdT_&!N>Cnl5
z+lXKDpdbU#iscgE=*8nSG;h`7Uu6eYL51gU3c$Ez)72=4G2X@SerNvC{<}c;-BkhF
z4etxshTo0|??g<@yPMIq04<4;H$?Vy%I+Sd2^|w5tp`&W0#NpqS&90okhX`4NPBUh
zvnZG%ihsv_cEV8xo|*scQOMr*RF#_)Xb;;NODw}L85E$9r#?%ttQixN#F_QMS?b1;
zI(T8t;}*x>Mv7J<-R{h4!bRKtmPSQv^6}wF-}C4a3N)R=bLApRNz<+p!5vDk4Smk+
zVCJ5kYyKu7PPl>>FPp>6c>-jP`dTJg>sDdseccyn+?Y|@6-AFA#ncHngG{F2oxu1a0`%JW$03h0zYlol8(6JxJfdnx@%P}GWrJgoOblmr0>@__D?Oson3e+^@0qj@HiDySrAM2
zSoWQJ*h^m0bYL^Ujxq)fjQ{1bWJ9$K2xTnY4zhi-_fK^7#jc}gxKBOfEqa&9938{X
zcf=A6{Ahlx@q}hxAL&e8FRq03(!7yi{`(b`Fx74ov#1RS_qhS1n)<6BZSSVazAm0O<2NFKdvvxz6f`DTQ;3jN`LexpYvG185`H~
zrsBXmT`#4jcOq8%?T^yJ{LRT+VJ>Z{nH#4x^^lEb{1&dK40JGVCjfrxa4Qt+&h+a@
zDO*Y_67PrgaQFlzSGHsK3#Fkn!k?dXP|H!8jmDhdWBRAJ6C7HoZ!l3MQc9FL#68WA
zn-G_&1>SVGQomIr*KIfKST7_K3p?j`UX^N#<09Lwhr*<(a(2B}sVMV=M
zL&3)x+$pzR;}2J$m?nvm-drb#hGqO)3&&bYa<7#mV)-^ZW54t3@Ez$dsrfv;`6{dc
z5{|E-pIAS2{acxo-9BRF>SXv$hYL9fv3Clc-S^3Axn^Wrb1oD@su5KrDIe}P;ADZt
z3`P28E2}IxiKG|Lzt-y7Te43E6Gam&_lO
zINwa>dTA3N{hEhDiTB!2&2b#G&`ly*PE-d5S!iY&u}&8}*(`8zv(1MMQUk8>$-YFD
z!dE{8JQLH0pp#+W#tl55(w}Bh*aQ0T0;}h_vKW%Or?((X*cI15U!dPeH0+pZ81Ewu
zsX<13^_lBGQZ}riGIM0qezeL-^J&n6e}rzau9>OuMM&|)V{x^qzc&T3&?$Zh-6Jg^
z@XSIn+_bcYd|ALs?t5O?GWk)H8frM&A9z}h_`Mqtlmx*Re)W7>1jrGyDv)xaMgEen
z;&sFTOaD;6ZAvqI@%xui>X8i^s;0Tq0Rk7y71KRYNR5NKtSf1kXHNnnGG~6BLJ;t}
zzs?-VP~%y?*CGGJaEnVQ^R3u08JX#UA1Y(yTy%^yfhh_SoK>h2netwH&G$kmYpu
z}?PnWiQw
zj0U~kDy5>*BA!_mm>C?6?|4c&XPNU8?{TUO_1e%^?Vkw-yzQxQT5ztxt0^sGvqtTo
zn$Dcqn~TQOK$Wee_AAx%&AVH%f2Sv6`#4V`m^^$sOo!gZIAKvrlN=fI+(G>=JCApr
zc5=R_kg>C`Inw|J*JZ`xw);gEdW89t!)Y%d4(loOG3L#wnt=x*30>i@0tw&zn$3Q7
zR;q(^B^4Wr(*H3rL2-Qu_ow7;{#&CG@^L20gX1do^a&YQXuniwxN+JPEX5XQ-y8NOM`RUUOpy%216y}Nd!#0=>w}80uf7q;!
z0#oxJ+o^G$KS)xcqZoy5V(QoHwI3eXpnTz9eM^P>i&63oqg?yJkKg<_lies5Nx5&*
z-)-!9gy_*PHsMmDM^!4zx|#%VkLE>wJH
z&xCOd5QY8E3jjM1NSX7-FgpfUs52ZTVZw?1ff5~ls6C`w6h;ItxZa1=ZFnsRkMku@
zasW%+dY0{hl7TP+wK}R1;oA;f0>KrG?2ROxwAy>D_C!CMi30uUBm^qk%7J{cu8(8;
zlpS=-&84RJcu>G28xNcI<7cJ8-s+Nyix^>UU|yu7gM%T{8RYhFdtlrg{wdvY$@uq5
z@|J$@N2w)V0e;!clYj;ald@@)I)u4ji(JbQ!BB`R7P%~9k!x)6W}j)ooh&_LrkAbk
zXfM8YnyXw*pz}$I+{hK6sqk{5oZx7WxUMSA
zLJ*4qXaa;l>xh^bz=+|MmP$?+Q93AI>|c+Ab;~5axYKEErnbnxDB^^g{0?s~ot8#P
zkm!UVYpwh!lUCZ9F){<0#n8S^Ax02+g>(#sJs01rd!&u`!_+&8Z(AGE@R(boKKb_|
zo0$-(wCk;|6eSrJBea+nZ1%$Z`Q?F;jmOg&)p+8x0c?6YK*U_rF|xbp${RKx0P-*l
z$GTXzw#)EwDHdS+q+2E>YL9qJCy0&g$Vk2d4LApbI}D1Q{Fq)vT`3Q`m$5`sWyTGU
z5-0!@3c!C}df%Ynymuhi$XlNfrOlSU%SCKQuDK$Oel_VoIw=iwg({8B;OIxhc3&2B
zdk&&GC;^(Svrs_UsfYRw1BUO~<5-A-20oS2f)OC#=DE9HZll7mHQVvPMgBWKT$3^XVt{u0>g-kjf(7~YN(4=Y_o8ofveI2
zz@s!tr$B9ENUIBDhGzLcHA!i*E^JhR-U8)*$t4vhGJ_M1J6U#yVU34!MX|0_HeH@r
z)3|DUZ4x@d5VY{bov%^E3ClvRv^mu%sBxfPO$=nj74=n
z3<4W13ebQ1ovOb>Kopq*1cP&h-?+{CFZqvSaGt_!P(moXQ3G{PIyzaoLX(&>ONDZAqt;q`7b`U*cEE4jo)tJXk(@zPCfiKox}uL5-S{Nx
zx**`}F=-Jh{G4$+UjzUAx3mBJR+`DhcnR^rvuQPmA14a;zTk-FJQpeg;%fYj@qfWa
zE=Jrvq|w^LTC`R3ZJsdY@V>spjK(a&ljU-k7GwPW<;H#JbXiKQ(}6hyE|jRfGJ>wGV58DvpifmEDb
zvwYcPd!iUUW6ze(TwpZ$l;S?d3vm>j3(8XL~Ll=BX7b
z7XtUsFaz&v{k$%h@n$KBlkiK6SZ3Pdm
z#?P0%`)3%oGFEGG!%LX^6@OP6#Q;@L+BoxJd5?lSUf(wCW2xDr45|VO0dT*w5;X+p
z{RF0X@(@HHrmAcQMZhEMg>PxZ-&}!sSAMN$
z#b-OF8Bv<+@(G6=z9KMNv0y$vE)`mZsF~)Y(*Bsf`6Eg5(mU?>=|n-CaRrE6F)u#f
zET$|Ob@_@vadT}E)>0|yTaes;x^pUsb6}Y1@xI>>>UcJ?ALxq>h)nO30v918YIq+K
zh#E)B98LZHnjrth8i$`)$i%DBShlj2;e|J24ZQonXH=myW!S3ekN<9(`!ldeZMH=Q
zN>py^l^(xkiP$V@bI=IUSLf$W+T}to9}cOg*@x1l<}ZC7%2UjozwN1^#`OEm9>Vla
zvP)R1#zmC2CsOB27!r2K+EKJ`I&-veU<`=Ok0;jz7l_Y+Gy^~@j{zZ$#H<76E-Hx
zTwdO%?ktNa6TRKnEILh44m}Bp-42P|4{9n$dkOy*J}e!6IA>rSZsSA~>(4;8kY37}Z((t5GvQQe_^Ad|S5Z2}tMTgtG39
zqqKkQ|IV1sU$6hkFnmLczy=tboigWqYDuUrz#}v9*Fsr|5p_C@1wZ8CwzthZoL@@~
zBpc`ds{2HvtgNt*tmi^j?;awFDP-xkeEV!a;L7;HI`tB1K9Udn=wF;kT7T<5od9+P
zk`P|T=xjB++g~~DKY#tYjnk8~jh*ivuC=z73qRwRAvVTM3$$q1x4!adyQndnOK1!{
z402dF#CV#>wgFLy8jP3dzC8pN@0c3y{k+(HMtitQbg?t_xa1i_MU!*lV@3-@2KrZP
z{0)o47Ck0zS
z34CEukCe|D@qTCBJB8c4oj$|(+gB{Ph#)TbHJ7p#ZXF~22Sw@3kk{OVDs^m9b(WO{
z+n?I@q$(hQ+vEKS>^SEbxAQiD5?M>55S@~Yh9K2xmwy+G%7{<%t?byi9)GrZ&_u!V
zezUIU=(Y5se?;P;)Tq36x1aPv(x|0pzCc<^&!sQZ?&kEF&)XF0j5nffeV19sF)O5B
zsoV1^XV-Z{gCkEsIqZiINJd`El9}Y=-64>bM8)BS>$*{XB&?;<5Gpd}7(Mhift6sY
z4EhX)I|LXzJaoQy+Cbg*RW36NAWDFEl~N4BL$Xcv=?hEHhe$<<;W>+SP$gzyJRTqj
z&l7vQIc*{38228uG3Y@EGFS0gv=&OPFcid;vXD!{mv(#mq17wVyb1uR199^@RNOfq
zFjbug$O3G$w~x^XIrz09>LI4Sd%ww;S0D)r)g)x2Mxfg*dr8MHLbO2g{XRRyl+o=h
z<6XoyO8j{2?M6Ia&|G%n)O_;YCy{19fxtw2P@geJXg>XOo^T=Yrnh5o>zryYtda;@
zMD%ZU5OI-CDOWugKv4AF+yxOoawueef~aZS`6rtCxw6e6&j5lf;1%zwaF?O`C6JFS
zn9q)-C+frw3X^Pd$(yJX7W3M;uB{8Wsju)WTJUo7{g&?hZBK$pn0S9_7%F7$t?4PJ
z_)l2hwCuTA5E)3aVCgdTszKrbXAeUASNH#C+=ppy)62od!uiFoxbo{qo^m2UeIh{_Idqe11TM2jPAGh@T-`gz!4>8P7Pa87Vvu
Q<5ULEpQ*@~%b5iKACnN7M*si-
literal 0
HcmV?d00001
diff --git a/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/Hitomi.kt b/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/Hitomi.kt
new file mode 100644
index 000000000..27c4a26d9
--- /dev/null
+++ b/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/Hitomi.kt
@@ -0,0 +1,615 @@
+package eu.kanade.tachiyomi.extension.all.hitomi
+
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.await
+import eu.kanade.tachiyomi.source.model.Filter
+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.model.UpdateStrategy
+import eu.kanade.tachiyomi.source.online.HttpSource
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+import okhttp3.Call
+import okhttp3.Request
+import okhttp3.Response
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.security.MessageDigest
+import java.text.SimpleDateFormat
+import java.util.LinkedList
+import java.util.Locale
+import kotlin.math.min
+
+@OptIn(ExperimentalUnsignedTypes::class)
+class Hitomi(
+ override val lang: String,
+ private val nozomiLang: String,
+) : HttpSource() {
+
+ override val name = "Hitomi"
+
+ private val domain = "hitomi.la"
+
+ override val baseUrl = "https://$domain"
+
+ private val ltnUrl = "https://ltn.$domain"
+
+ override val supportsLatest = true
+
+ private val json: Json by injectLazy()
+
+ override val client = network.cloudflareClient
+
+ override fun headersBuilder() = super.headersBuilder()
+ .set("referer", "$baseUrl/")
+ .set("origin", baseUrl)
+
+ override fun fetchPopularManga(page: Int): Observable = Observable.fromCallable {
+ runBlocking { getPopularManga(page) }
+ }
+
+ private suspend fun getPopularManga(page: Int): MangasPage {
+ val entries = getGalleryIDsFromNozomi("popular", "today", nozomiLang, page.nextPageRange())
+ .toMangaList()
+
+ return MangasPage(entries, entries.size >= 24)
+ }
+
+ override fun fetchLatestUpdates(page: Int): Observable = Observable.fromCallable {
+ runBlocking { getLatestUpdates(page) }
+ }
+
+ private suspend fun getLatestUpdates(page: Int): MangasPage {
+ val entries = getGalleryIDsFromNozomi(null, "index", nozomiLang, page.nextPageRange())
+ .toMangaList()
+
+ return MangasPage(entries, entries.size >= 24)
+ }
+
+ private lateinit var searchResponse: List
+
+ override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = Observable.fromCallable {
+ runBlocking { getSearchManga(page, query, filters) }
+ }
+
+ private suspend fun getSearchManga(page: Int, query: String, filters: FilterList): MangasPage {
+ if (page == 1) {
+ searchResponse = hitomiSearch(
+ query.trim(),
+ filters.filterIsInstance().firstOrNull()?.state == 0,
+ nozomiLang,
+ ).toList()
+ }
+
+ val end = min(page * 25, searchResponse.size)
+ val entries = searchResponse.subList((page - 1) * 25, end)
+ .toMangaList()
+
+ return MangasPage(entries, end != searchResponse.size)
+ }
+
+ private class SortFilter : Filter.Select("Sort By", arrayOf("Popularity", "Updated"))
+
+ override fun getFilterList(): FilterList {
+ return FilterList(SortFilter())
+ }
+
+ private fun Int.nextPageRange(): LongRange {
+ val byteOffset = ((this - 1) * 25) * 4L
+ return byteOffset.until(byteOffset + 100)
+ }
+
+ private suspend fun getRangedResponse(url: String, range: LongRange?): ByteArray {
+ val rangeHeaders = when (range) {
+ null -> headers
+ else -> headersBuilder()
+ .set("Range", "bytes=${range.first}-${range.last}")
+ .build()
+ }
+
+ return client.newCall(GET(url, rangeHeaders)).awaitSuccess().use { it.body.bytes() }
+ }
+
+ private suspend fun hitomiSearch(
+ query: String,
+ sortByPopularity: Boolean = false,
+ language: String = "all",
+ ): Set =
+ coroutineScope {
+ val terms = query
+ .trim()
+ .replace(Regex("""^\?"""), "")
+ .lowercase()
+ .split(Regex("\\s+"))
+ .map {
+ it.replace('_', ' ')
+ }
+
+ val positiveTerms = LinkedList()
+ val negativeTerms = LinkedList()
+
+ for (term in terms) {
+ if (term.startsWith("-")) {
+ negativeTerms.push(term.removePrefix("-"))
+ } else if (term.isNotBlank()) {
+ positiveTerms.push(term)
+ }
+ }
+
+ val positiveResults = positiveTerms.map {
+ async {
+ runCatching {
+ getGalleryIDsForQuery(it, language)
+ }.getOrDefault(emptySet())
+ }
+ }
+
+ val negativeResults = negativeTerms.map {
+ async {
+ runCatching {
+ getGalleryIDsForQuery(it, language)
+ }.getOrDefault(emptySet())
+ }
+ }
+
+ val results = when {
+ sortByPopularity -> getGalleryIDsFromNozomi(null, "popular", language)
+ positiveTerms.isEmpty() -> getGalleryIDsFromNozomi(null, "index", language)
+ else -> emptySet()
+ }.toMutableSet()
+
+ fun filterPositive(newResults: Set) {
+ when {
+ results.isEmpty() -> results.addAll(newResults)
+ else -> results.retainAll(newResults)
+ }
+ }
+
+ fun filterNegative(newResults: Set) {
+ results.removeAll(newResults)
+ }
+
+ // positive results
+ positiveResults.forEach {
+ filterPositive(it.await())
+ }
+
+ // negative results
+ negativeResults.forEach {
+ filterNegative(it.await())
+ }
+
+ results
+ }
+
+ // search.js
+ private suspend fun getGalleryIDsForQuery(
+ query: String,
+ language: String = "all",
+ ): Set {
+ query.replace("_", " ").let {
+ if (it.indexOf(':') > -1) {
+ val sides = it.split(":")
+ val ns = sides[0]
+ var tag = sides[1]
+
+ var area: String? = ns
+ var lang = language
+ when (ns) {
+ "female", "male" -> {
+ area = "tag"
+ tag = it
+ }
+
+ "language" -> {
+ area = null
+ lang = tag
+ tag = "index"
+ }
+ }
+
+ return getGalleryIDsFromNozomi(area, tag, lang)
+ }
+
+ val key = hashTerm(it)
+ val node = getGalleryNodeAtAddress(0)
+ val data = bSearch(key, node) ?: return emptySet()
+
+ return getGalleryIDsFromData(data)
+ }
+ }
+
+ private suspend fun getGalleryIDsFromData(data: Pair): Set {
+ val url = "$ltnUrl/galleriesindex/galleries.$galleriesIndexVersion.data"
+ val (offset, length) = data
+ require(length in 1..100000000) {
+ "Length $length is too long"
+ }
+
+ val inbuf = getRangedResponse(url, offset.until(offset + length))
+
+ val galleryIDs = mutableSetOf()
+
+ val buffer =
+ ByteBuffer
+ .wrap(inbuf)
+ .order(ByteOrder.BIG_ENDIAN)
+
+ val numberOfGalleryIDs = buffer.int
+
+ val expectedLength = numberOfGalleryIDs * 4 + 4
+
+ require(numberOfGalleryIDs in 1..10000000) {
+ "number_of_galleryids $numberOfGalleryIDs is too long"
+ }
+ require(inbuf.size == expectedLength) {
+ "inbuf.byteLength ${inbuf.size} != expected_length $expectedLength"
+ }
+
+ for (i in 0.until(numberOfGalleryIDs))
+ galleryIDs.add(buffer.int)
+
+ return galleryIDs
+ }
+
+ private tailrec suspend fun bSearch(
+ key: UByteArray,
+ node: Node,
+ ): Pair? {
+ fun compareArrayBuffers(
+ dv1: UByteArray,
+ dv2: UByteArray,
+ ): Int {
+ val top = min(dv1.size, dv2.size)
+
+ for (i in 0.until(top)) {
+ if (dv1[i] < dv2[i]) {
+ return -1
+ } else if (dv1[i] > dv2[i]) {
+ return 1
+ }
+ }
+
+ return 0
+ }
+
+ fun locateKey(
+ key: UByteArray,
+ node: Node,
+ ): Pair {
+ for (i in node.keys.indices) {
+ val cmpResult = compareArrayBuffers(key, node.keys[i])
+
+ if (cmpResult <= 0) {
+ return Pair(cmpResult == 0, i)
+ }
+ }
+
+ return Pair(false, node.keys.size)
+ }
+
+ fun isLeaf(node: Node): Boolean {
+ for (subnode in node.subNodeAddresses)
+ if (subnode != 0L) {
+ return false
+ }
+
+ return true
+ }
+
+ if (node.keys.isEmpty()) {
+ return null
+ }
+
+ val (there, where) = locateKey(key, node)
+ if (there) {
+ return node.datas[where]
+ } else if (isLeaf(node)) {
+ return null
+ }
+
+ val nextNode = getGalleryNodeAtAddress(node.subNodeAddresses[where])
+ return bSearch(key, nextNode)
+ }
+
+ private suspend fun getGalleryIDsFromNozomi(
+ area: String?,
+ tag: String,
+ language: String,
+ range: LongRange? = null,
+ ): Set {
+ val nozomiAddress = when (area) {
+ null -> "$ltnUrl/$tag-$language.nozomi"
+ else -> "$ltnUrl/$area/$tag-$language.nozomi"
+ }
+
+ val bytes = getRangedResponse(nozomiAddress, range)
+ val nozomi = mutableSetOf()
+
+ val arrayBuffer = ByteBuffer
+ .wrap(bytes)
+ .order(ByteOrder.BIG_ENDIAN)
+
+ while (arrayBuffer.hasRemaining())
+ nozomi.add(arrayBuffer.int)
+
+ return nozomi
+ }
+
+ private val galleriesIndexVersion by lazy {
+ client.newCall(
+ GET("$ltnUrl/galleriesindex/version?_=${System.currentTimeMillis()}", headers),
+ ).execute().use { it.body.string() }
+ }
+
+ private data class Node(
+ val keys: List,
+ val datas: List>,
+ val subNodeAddresses: List,
+ )
+
+ private fun decodeNode(data: ByteArray): Node {
+ val buffer = ByteBuffer
+ .wrap(data)
+ .order(ByteOrder.BIG_ENDIAN)
+
+ val uData = data.toUByteArray()
+
+ val numberOfKeys = buffer.int
+ val keys = ArrayList()
+
+ for (i in 0.until(numberOfKeys)) {
+ val keySize = buffer.int
+
+ if (keySize == 0 || keySize > 32) {
+ throw Exception("fatal: !keySize || keySize > 32")
+ }
+
+ keys.add(uData.sliceArray(buffer.position().until(buffer.position() + keySize)))
+ buffer.position(buffer.position() + keySize)
+ }
+
+ val numberOfDatas = buffer.int
+ val datas = ArrayList>()
+
+ for (i in 0.until(numberOfDatas)) {
+ val offset = buffer.long
+ val length = buffer.int
+
+ datas.add(Pair(offset, length))
+ }
+
+ val numberOfSubNodeAddresses = 16 + 1
+ val subNodeAddresses = ArrayList()
+
+ for (i in 0.until(numberOfSubNodeAddresses)) {
+ val subNodeAddress = buffer.long
+ subNodeAddresses.add(subNodeAddress)
+ }
+
+ return Node(keys, datas, subNodeAddresses)
+ }
+
+ private suspend fun getGalleryNodeAtAddress(address: Long): Node {
+ val url = "$ltnUrl/galleriesindex/galleries.$galleriesIndexVersion.index"
+
+ val nodedata = getRangedResponse(url, address.until(address + 464))
+
+ return decodeNode(nodedata)
+ }
+
+ private fun hashTerm(term: String): UByteArray {
+ return sha256(term.toByteArray()).copyOfRange(0, 4).toUByteArray()
+ }
+
+ private fun sha256(data: ByteArray): ByteArray {
+ return MessageDigest.getInstance("SHA-256").digest(data)
+ }
+
+ private suspend fun Collection.toMangaList() = coroutineScope {
+ map { id ->
+ async {
+ runCatching {
+ client.newCall(GET("$ltnUrl/galleries/$id.js", headers))
+ .awaitSuccess()
+ .parseScriptAs()
+ .toSManga()
+ }.getOrNull()
+ }
+ }.awaitAll().filterNotNull()
+ }
+
+ private suspend fun Gallery.toSManga() = SManga.create().apply {
+ title = this@toSManga.title
+ url = galleryurl
+ author = groups?.joinToString { it.formatted }
+ artist = artists?.joinToString { it.formatted }
+ genre = tags?.joinToString { it.formatted }
+ thumbnail_url = files.first().let {
+ val hash = it.hash
+ val imageId = imageIdFromHash(hash)
+ val subDomain = 'a' + subdomainOffset(imageId)
+
+ "https://${subDomain}tn.$domain/webpbigtn/${thumbPathFromHash(hash)}/$hash.webp"
+ }
+ description = buildString {
+ characters?.joinToString { it.formatted }?.let {
+ append("Characters: ", it, "\n")
+ }
+ parodys?.joinToString { it.formatted }?.let {
+ append("Parodies: ", it, "\n")
+ }
+ }
+ status = SManga.COMPLETED
+ update_strategy = UpdateStrategy.ONLY_FETCH_ONCE
+ initialized = true
+ }
+
+ override fun mangaDetailsRequest(manga: SManga): Request {
+ val id = manga.url
+ .substringAfterLast("-")
+ .substringBefore(".")
+
+ return GET("$ltnUrl/galleries/$id.js", headers)
+ }
+
+ override fun mangaDetailsParse(response: Response): SManga {
+ return response.parseScriptAs().let {
+ runBlocking { it.toSManga() }
+ }
+ }
+
+ override fun getMangaUrl(manga: SManga) = baseUrl + manga.url
+
+ override fun chapterListRequest(manga: SManga): Request {
+ val id = manga.url
+ .substringAfterLast("-")
+ .substringBefore(".")
+
+ return GET("$ltnUrl/galleries/$id.js#${manga.url}", headers)
+ }
+
+ override fun chapterListParse(response: Response): List {
+ val gallery = response.parseScriptAs()
+ val mangaUrl = response.request.url.fragment!!
+
+ return listOf(
+ SChapter.create().apply {
+ name = "Chapter"
+ url = mangaUrl
+ scanlator = gallery.type
+ date_upload = runCatching {
+ dateFormat.parse(gallery.date.substringBeforeLast("-"))!!.time
+ }.getOrDefault(0L)
+ },
+ )
+ }
+
+ private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH)
+
+ override fun getChapterUrl(chapter: SChapter) = baseUrl + chapter.url
+
+ override fun pageListRequest(chapter: SChapter): Request {
+ val id = chapter.url
+ .substringAfterLast("-")
+ .substringBefore(".")
+
+ return GET("$ltnUrl/galleries/$id.js", headers)
+ }
+
+ override fun pageListParse(response: Response): List {
+ val gallery = response.parseScriptAs()
+
+ return gallery.files.mapIndexed { idx, img ->
+ runBlocking {
+ val hash = img.hash
+ val commonId = commonImageId()
+ val imageId = imageIdFromHash(hash)
+ val subDomain = 'a' + subdomainOffset(imageId)
+
+ Page(
+ idx,
+ "$baseUrl/reader/$id.html",
+ "https://${subDomain}a.$domain/webp/$commonId$imageId/$hash.webp",
+ )
+ }
+ }
+ }
+
+ override fun imageRequest(page: Page): Request {
+ val imageHeaders = headersBuilder()
+ .set("Accept", "image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8")
+ .set("Referer", page.url)
+ .build()
+
+ return GET(page.imageUrl!!, imageHeaders)
+ }
+
+ private inline fun Response.parseScriptAs(): T =
+ parseAs { it.substringAfter("var galleryinfo = ") }
+
+ private inline fun Response.parseAs(transform: (String) -> String = { body -> body }): T {
+ val body = use { it.body.string() }
+ val transformed = transform(body)
+
+ return json.decodeFromString(transformed)
+ }
+
+ private suspend fun Call.awaitSuccess() =
+ await().also {
+ require(it.isSuccessful) {
+ it.close()
+ "HTTP error ${it.code}"
+ }
+ }
+
+ // ------------------ gg.js ------------------
+ private var scriptLastRetrieval: Long? = null
+ private val mutex = Mutex()
+ private var subdomainOffsetDefault = 0
+ private val subdomainOffsetMap = mutableMapOf()
+ private var commonImageId = ""
+
+ private suspend fun refreshScript() = mutex.withLock {
+ if (scriptLastRetrieval == null || (scriptLastRetrieval!! + 60000) < System.currentTimeMillis()) {
+ val ggScript = client.newCall(
+ GET("$ltnUrl/gg.js?_=${System.currentTimeMillis()}", headers),
+ ).awaitSuccess().use { it.body.string() }
+
+ subdomainOffsetDefault = Regex("var o = (\\d)").find(ggScript)!!.groupValues[1].toInt()
+ val o = Regex("o = (\\d); break;").find(ggScript)!!.groupValues[1].toInt()
+
+ subdomainOffsetMap.clear()
+ Regex("case (\\d+):").findAll(ggScript).forEach {
+ val case = it.groupValues[1].toInt()
+ subdomainOffsetMap[case] = o
+ }
+
+ commonImageId = Regex("b: '(.+)'").find(ggScript)!!.groupValues[1]
+
+ scriptLastRetrieval = System.currentTimeMillis()
+ }
+ }
+
+ // m <-- gg.js
+ private suspend fun subdomainOffset(imageId: Int): Int {
+ refreshScript()
+ return subdomainOffsetMap[imageId] ?: subdomainOffsetDefault
+ }
+
+ // b <-- gg.js
+ private suspend fun commonImageId(): String {
+ refreshScript()
+ return commonImageId
+ }
+
+ // s <-- gg.js
+ private fun imageIdFromHash(hash: String): Int {
+ val match = Regex("(..)(.)$").find(hash)
+ return match!!.groupValues.let { it[2] + it[1] }.toInt(16)
+ }
+
+ // real_full_path_from_hash <-- common.js
+ private fun thumbPathFromHash(hash: String): String {
+ return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1")
+ }
+
+ override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
+ override fun popularMangaRequest(page: Int) = throw UnsupportedOperationException()
+ override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException()
+ override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException()
+ override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException()
+ override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
+ override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
+}
diff --git a/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/HitomiDto.kt b/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/HitomiDto.kt
new file mode 100644
index 000000000..0e5a6d5be
--- /dev/null
+++ b/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/HitomiDto.kt
@@ -0,0 +1,82 @@
+package eu.kanade.tachiyomi.extension.all.hitomi
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonPrimitive
+
+@Serializable
+data class Gallery(
+ val galleryurl: String,
+ val title: String,
+ val date: String,
+ val type: String,
+ val tags: List?,
+ val artists: List?,
+ val groups: List?,
+ val characters: List?,
+ val parodys: List?,
+ val files: List,
+)
+
+@Serializable
+data class ImageFile(
+ val hash: String,
+)
+
+@Serializable
+data class Tag(
+ val female: JsonPrimitive?,
+ val male: JsonPrimitive?,
+ val tag: String,
+) {
+ val formatted get() = if (female?.content == "1") {
+ "${tag.toCamelCase()} (Female)"
+ } else if (male?.content == "1") {
+ "${tag.toCamelCase()} (Male)"
+ } else {
+ tag.toCamelCase()
+ }
+}
+
+@Serializable
+data class Artist(
+ val artist: String,
+) {
+ val formatted get() = artist.toCamelCase()
+}
+
+@Serializable
+data class Group(
+ val group: String,
+) {
+ val formatted get() = group.toCamelCase()
+}
+
+@Serializable
+data class Character(
+ val character: String,
+) {
+ val formatted get() = character.toCamelCase()
+}
+
+@Serializable
+data class Parody(
+ val parody: String,
+) {
+ val formatted get() = parody.toCamelCase()
+}
+
+private fun String.toCamelCase(): String {
+ val result = StringBuilder(length)
+ var capitalize = true
+ for (char in this) {
+ result.append(
+ if (capitalize) {
+ char.uppercase()
+ } else {
+ char.lowercase()
+ },
+ )
+ capitalize = char.isWhitespace()
+ }
+ return result.toString()
+}
diff --git a/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/HitomiFactory.kt b/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/HitomiFactory.kt
new file mode 100644
index 000000000..fd3667021
--- /dev/null
+++ b/src/all/hitomi/src/eu/kanade/tachiyomi/extension/all/hitomi/HitomiFactory.kt
@@ -0,0 +1,34 @@
+package eu.kanade.tachiyomi.extension.all.hitomi
+
+import eu.kanade.tachiyomi.source.SourceFactory
+
+class HitomiFactory : SourceFactory {
+ override fun createSources() = listOf(
+ Hitomi("all", "all"),
+ Hitomi("en", "english"),
+ Hitomi("id", "indonesian"),
+ Hitomi("jv", "javanese"),
+ Hitomi("ca", "catalan"),
+ Hitomi("ceb", "cebuano"),
+ Hitomi("cs", "czech"),
+ Hitomi("da", "danish"),
+ Hitomi("de", "german"),
+ Hitomi("et", "estonian"),
+ Hitomi("es", "spanish"),
+ Hitomi("eo", "esperanto"),
+ Hitomi("fr", "french"),
+ Hitomi("it", "italian"),
+ Hitomi("hi", "hindi"),
+ Hitomi("hu", "hungarian"),
+ Hitomi("pl", "polish"),
+ Hitomi("pt", "portuguese"),
+ Hitomi("vi", "vietnamese"),
+ Hitomi("tr", "turkish"),
+ Hitomi("ru", "russian"),
+ Hitomi("uk", "ukrainian"),
+ Hitomi("ar", "arabic"),
+ Hitomi("ko", "korean"),
+ Hitomi("zh", "chinese"),
+ Hitomi("ja", "japanese"),
+ )
+}