zrCxN~oipjU54d|>JtWB<3$8 TWKF@Sp?sj;b01o+pDlDT2!fp?kwDi1)1$UPB*{Vp`M<(U?)|Sx+ zZn(FYBpE6~1&CBMNADcgW<)PCz{glgUnnNv@S#ISSp6q!?0fw0CX8u0dNMX^6E>5bXgD3ngl$rtp3z_$ NX2nY!7V=31Ww!#jvj`r#&_MQ!DCl`8L0!K_Wq5L@*b{SU%xh=bms9+UxX? zv-{bN<@q;^b5il^K{Y|0qXVy~D|v;j%T#5JU&5}!Q=Z@M-eT_)+sHJoe0m($UQnR% z^K*dS0Pkdo;bq#OGe2ufsjUhHxLml>U=w>Jb#X~l><@EpK~O}!kOp*oYPKL=x8RpO zeW^;v25f6Py!4Yw+E|R|Asa_(IKQFR{IrW0W&BY&ZQzk}>B{43;h7PH%kvGom7|{U ztO%Pqz=5i1Jy^?A1HjV?6oq1U`n2|w)JAsVT}wW~AT6(isZ(Tthg>@>S4H6iwauex zj=HnPGu_{-FT6c9!FA-c150Y~U}%>dzKg~* L4FADYU2A6Z(DUODTr^Z Mr{fu zTeD%0zFUMEKviZ;(~8sIz)KS`IIlN9!u_Y2yO1F__1*XXvY+omn*G#v#p7 a#g^BeS9A`W+ep=RHjwB olBYOSpFW zy7ZugVJddRnwR2p`zP&JoMWh;9J~r=%i14yoi2xIS(-N}*T~0?e7W7`$p*Fo;|-+# zFUqL;ZBYi70DB>oaT8hi?_TL`@U@o2rAWqIxv}k$!!)77F>sS_a_;!=Rtr7* zzt1a `o1w;SZbg^? ziPmnde;voIJNntUoV;2T8$aRR3(wzM{jA$}4TiC#ZGX m5o jQ_+gS%G*ljq9(2Z`7|KXiS5oY&;acH#-zx*^1Y?$dpY!-Mi!LG?OmF(Gy-D zK{1W#%tJzW@-C~+vvqTM++_0kD!yg0N^Z3mTQ$cmJn9p#ymsp0*Usn2rr={i N!bedvNQQvlSwxi zaLWSOrLjJ4L_lFKZ81_Ut!`5L%C|=N79RmCGesRtxsA>D+|-Gew*mYlPccUM^0) z&T(g7E6hk0t{gQ-@@fj7uD#6tm8UR5St4DCQ25Sdu+k*o*Z8ASe> &rb#3I7_-jNh%B+s2 z1Pw>LX$!}Ap=+a?+ttocqU)Gcy2SU^`HnI|5}CA--4$fbRQ}~9os0fM_tH|2&0O3a zkArTb3-ltlv^9-Qox}IMpvowz!HDhq2rGX0FxQ (WRWkVGA z&fPML(0j3-E?#>tN106;h?b?mVI-8P6E-HRYG5_H$VB_~8`rn+!=rR< ;Y4ftxgZMpM0i}k?+@&}g`H-+9x&8`oHqI-q+mJK z4q#Jc_-S{4%!ZEDh@1a>R4?-8(t(OmSN%*bQb?2~(cf#aADnFcU_J*|BXg^p4XNsa z Qebu0>+KU--s48JVma4q7PpVo MpbYrQk#uU-^! zP$5FnhjYr_l^)MftOzgOSAWAxqo&JHJcv~zy1OyG5DQ! ~tWLe@(36kanTRf^(`u&_JRS$DAPOQv!2n+Mh?;(scGMTZM pmQ8#wxEB=_`F|uft{;6yKaS~rad@5U%lZEkfY3MAE7x%j{~sVe9{K =2nK`|(9%tu^aOVsB#gQku?4`%?v>!WXQvu@6P8 z)Utl7LGUkG@7luD4v$4%?C
Xtd1Vo%*@3urU9-P^g=38NSSgw+LoGJhP^r zml-%0{Df1VM;PWY)9iC=IemiV@?D^t{A1bmd0^mClTRJ15ytm5tE}X;^3I6~G`smq zMORmsS+;}*96R&=0!KOW`VrXougSn-{il}>);$C28lXYRL7CwO-D{GW8#~eWu14be zW7Rbn{~a3;K0BBKXdFft7kyw9y1^*K !<%Y z&L-p}hqta8rdwi`&QOPD+<{ugB}c|s0Ixp*#Y?`IHZNmD NpnIlNDvla6$R+Pv@Wd&g#+MlFjCMEh@uDd7~BgP za8x;IDGGaBeRYJ-`ZP8$jB|f+LychJKK&0~jc_8%Gb I9B+kGE&`#}u(F z7_dVUS)9f@jme$7ilDv>bG2zQo#??0WLbGY&4A;x8Zns{EfmzKQZ7TiH=l*B6vl@G zf&pto30fl&{~WNFw(`^a53w7rmxL#MV58UlV3nTCT4(Vq$UU8Ty>S`4h33VTABX~Q z b74Tw?0xR`Qh)BwG;AnfpF*mRna&RKM>)vZ|P1A5)Ol|f#_iFSwCODV_qworh z1ZmJcde^%&6>dPY5Dq3iGrQP$lorouUtuJZ-sT-qWP6w9m2NO5xB>KYi)BKl?cTm( zFg!9)xP62Zdz}IkOwirBY{<)vL2U{E_h?=O?=Ugp#IKDWhQi++ugz>2?5 =yBgy9q1BE+cNbcE=)EY;$ zE6Z }*; z aHV40CCWoxAFm&aAsAc^IC>!}lcO zyYvwC#%4!?D}?n6cb_>O@g%_r;J6D+&0X!;JDK}KHDxZ96h{)Cv*E 5`tLQ1!6NKD(D}1-bGl2e)NR9!Tu=AyI8KD}I2dk5@78l6Rpt(8 zsSLoqCTK@nI|QQ!$R))uT0GD5;8E}cb4T&W##0XL&k 52NBR?SZ zZN-*8LDR5FVlVY5aPMTT!iOIda1oOD3^aN_>$~)Au@Ub;M{OPwWYq>;>G_~}uEQ(_ zo9$e02 fS9&<(M zsnw173r}Jxx0QOSk19=_UeY`f4c5-2xwPZiR}%T) Z0hd>_~+fVDg3SRz`sRIU?`xA#umhAwdw$w>0d&OgmK8fgUY)K w z?zWa@n6e}oG&qZtD$TQ4ZMcmloG`bV;u()IZMrSr$a;}!@zhppr$|Gf&YMqh^T$TX z2+=)y?DC}%?+^Cb$8XxIbprcKs_5hD5&NcHcY6zO;?Ay9qQX&|5IC|5LW{nVVYs}( z)cG`n=jCd%yX}SYDCw4tjIAU-zW93YAabxjN~Dkz3;(%N zPxFiFlsoq~r`ELrtE`!|%7K(I2DcyT33Ky0bk6p+b8+CWDL_TOe(v!*M~b494PVge zk3)GS9euDxwQy6Dvb(qiY+^cmp?d2y&0xg)VQky^Iif~IIRLYlFnZJW`^L~EM$BBm z1;uxXk@1Xf8wI)Bqzwk@&qtyD$(-ACM5^-HsX8dKBODy_5ZD*goF##(n-qSS`Iag1 z-_w-};~E~tr+%&gP{v6spKhOFv#S91Z}eu!YJ#A-rRHjrzU6k+h+*7NhmPrRZjt}2 zS z@_-q| |h9`@Nb)s1J74Qgoelh>xyj#%fISu0n6ZcqL-&y5=Mvr5$Jv(^JGJ#1Bw^P?I59!hp#ogw%4Ts#3lt89W`Ymacby;Qr1wl{3}O?uT^Urj_I;V$OP!ZL z6mvnOW$3VhRK3n|iz^Y|i(|6F^S(tr$Hr= +<6_fk_7;~_N4gi`5X1?)iG zULoDp+#f@~IulUQ3>AShEGxZv)8-!~If4_wiFBRM?T+_Px_R-M{^3Kzl}!}=QEqFC z;#cjS#TSF<<8n{yOiFDB1!(EzCiC<4EZXcIhkO %W()66(q<;; 8O4>g%3XuzwbtQjW#7iJF$fj@?BtZ@GRI{%*b2LWNF-R1oTPo`S);zL*% zR(`=tzXk5LskMio9D41uPXhm4FSfQJ>>u2 }O!Gh-E&RpX`9kbr%0lCttDlO~w zNzTW(yk4SrQvV+bvoE+!%6XXJSkl+A2jSfU4l4&ap9WWJDdE9wQ916Tdz#gAhzm0B zXo15T$`vNY1z}ACfVe8OpVnb5za9UEm>Oa?rLeI1eC&P?nsL1` VtRK>!dv0Xp{`)^u|>OSQx~68q}K5n7r`P@AU6 zZ+yE$;P~e{i`$|kRt~oyH$C9N<}tWJy3tELblE=vAc@IiF-rV9>$^Xy!6B-~F5IJ? z>(g8}x0@;(!4+@$;Yj_=MSe;vE_dGbLX+@4M~KAO&YJo{dzgbaIqX~rps _;fDA6gt zE88Apw_sX6Wb2MQIwPA|WW |%@eN)BY=*$38K{YWu96fMd3`*$!@Ko9HPI2}FhxZStR1O?oSp?= zaUtjKZS3B7{d*KYDfz81B6pCN8|8R(mH7wp^#po{7O~2}fYEhJgh~acy>(?(%fYb4 z{Fj5|vskHyxal4Gcv93qE%V#vz7%F7pi0>uucxNcZc=%j7Th}Jk9rD?1ZcvO>^zXD zAM9+xyK?zYI13!p3g!8k`hcoTS@EzrKe+YcI4m!&{Jd$ZNhhjRu?qFLG??m>nwj)2 zlM#-2SR>AU^W3?f4JAuYh1ILn^AgLB6}N@Ap5m{hr`?WdarPN~Rpp^>KQD6)2?|mK zjx8H^wAT9TwXvo@s5A59s z4mk>r7dl|Ad|4(X^oId5ZD~%~B-`dJ=deVTghAL!lLV|h^>!9up;L70xtx*7EAS4- z6#_Np!eR)Y%E2<58U6YjhOX66Hk|vnO*K_6v05d~a|=mjfJ>Ntd^Wv1_F`PUH)f3L z!xiylbDARnXAu8jz+c2u%m|<=uQD4pb=>wyAT%oR&C5?q{isjH#}h4e-nVQtsd T>*Y8rSpN5e)VOR z>BW9dqTfi~)4c+qo4xCseygOmDL;a%S|AnQRNhL<+r!aicR-ODR)q@NA7$DFLn_Tj z?6?9dq?8d?{U>D5Y!8_iw>N3^ZPGc#T#oZXlZ*J0@uMWXw{4%*y%i2az@g`R=jXpn zhQ>F9UmfTvA1)qE 49N*K5o4u8#u8z{Y;Fjamu3o?0aDAKNa5T@`}Y zu6){m+Z7sy=7F%v#3ffxNVPG<@19vcP&vd0)`LB+LmHNifGsQA5f+%B8>V-A%$-3s z 49E$Ft*`fE(dQC|8G4C40B%JlPDUp7mYJ^K z`X7R{XkCz9uKfa7jV`?si4%^TX+cqr6Aq4!7DI*9A^*avWgfhhe-#8-8Ods_56!p- z%}|3{BB7Qo?&DRO**E_MT{N>^1-S^ns%*cS^e=I>V9`sFrfllPZ0aj)>f08r0b3?2 zDk}2cF^0%bAH8N<5=^{&-rQfz7GJy|@#-P)s7LTvNUT>gz29ZX4ox|z&OxC0@hoi6 zcX!_DMJ76DKXg1}J@Cmq+Z_})%Ya+b` 9)0bnVH1@jWgdult;Hc9Dor?3f>7(h>cx z`(L~iDBuxQ^5zpaclWFWeic|-Tbs1JJT%BLjumT-&Bo4PFXUV2XD=+-4S%|s;3v*D zhxj38*ppU9icFj)aX06&?u};Na#tmfJ@lrOmX)p9>*?vCLqe3WoGlG!mch$(%k<08 zWf(qA@HBL|>{{K?RfU(V$Xe~#SIjaazlQNw1D`Rrrm^k;r@ky4>SnC!{6dixmm`&5 znYr|>go)rgVZ*V}@><)=*_Pc3H(&dG(A5oFu>$I#6LqG;h*fyiRIoCtH3D6B#Hzl% zI5ZtWbCMq!uu{uNKVnee-d>z(&hm@$UEx|FK}gB$$IWcv(CA`f!3OC>Z?-RRUyqb# z)0d{`c7)}Oj3wC)E7ZL6`y)5}mXv+theEWN^WN3_fWC107lX~>9cG*dD#c&bz;$-8 zV@EhwL0ufqLG<#o2jRb155GO1eIr>RI!PZ@q5_((FntO!SaHi8Oi_uD@~&_GrYU~& z5m7bS&p{Ed$&A+(!Ru$hiC0rj^o8vQB{{^wr;ZO!^vwLq)x@ fo_r^h?!x`U0 zFLg+~s8vtwH5}%AV>WT6Suj~uDmG6Cs($tnM8=HACF-#(3mu}517SCiX9Qi-2sbND q&E@9cz3bu `KKA69XfaQHLBF@U;GCoSz0v! literal 0 HcmV?d00001 diff --git a/src/ja/ganma/res/web_hi_res_512.png b/src/ja/ganma/res/web_hi_res_512.png new file mode 100644 index 0000000000000000000000000000000000000000..416635d5b8e617d16f2e81dfb98396eee3e33510 GIT binary patch literal 4123 zcmcIn`BxI?_jbc_&kdE_GqoZ^&5|IM)X*6+%rdtWTrk7Tq}(fUp>Z_PG=p+)avIB( z(p<(37gUnm#lj857NlIpFt-o=58v-S&w1~C?|IJs;l9grZaUfxsU#1QmynQ9a&>XK zAR!?o4y7daN{b!6+=L-^_tB_}&bwmU{I;|CZTIK5o%NZW<@Y &i>@fNpai(9-slfCr| P8g)V)nH#v?b! zZ)}Z4Zi+F4*&GVke2>{=qJN6f>+q8f*`Pyyc0x8<4L6$hiQY);_uKW5kWe}A>V&u$ zE4kD`jGuCO0(hUXb>EOy+B-{n`^Wwxbw|p-wC=7hooxO {Zhm*|&2dKRrGe&ckFSor{mDPh3nSj7qL6P|60QXtus0F?y s%6Z@t#gTt9|{^ze=&R%{YP5{LT4bLL_-h>pz@XyA3cn#T-mGJw(0)(u4Bg0)x zdrAA7F%-)=Q*~_tYJpW~eDY{flv;_X!Y)AFI&}WJafG!juRQ$n(c;^*qko+okJ{=4 z4rlZ`1C78&8%qZ=hCn9Ok3!vbO`d(}8cy9!=GBqGXneLVlZL`7xHgc%`6pdKt6*=q z!v( q6M!44{W95q7g(|H#QU=KUKL zKQ9?YJx|Q#89tsQRJ0(bZ_nQXq_kAV^YhXU@{X?er^6iXzLPRzA?_}C@Dyzstm_9t zOVhTP`Gn=Bq^$?s#1;#~dvmsC9frFdH~o_lI}f-YWm2{>xLe}Ypcg7HyJ3*Ey4I8= zF-fTM*V-2j4bWdnCyWOQ!BOM7{C85JgV;fE DBLk z3~FRs)F7)Bn19yVFZ-tXkFo?TIYJ=u&hq}XUeG!}Nhgf09`Gz+TxR_M2p7b 2g8$TIZw2GbP<|Nydl$7wPeH1EQBQ(AOMBkG z!g^wfa@M&2{ID|m#)K$JO+NHax2+e(jnp?~`3xNdjsW3!mfpmT7SLH9Bl85syf?A+ zvi75UPB?WS#U#iMnfU;K<3e1I(l+$CLBvKUx{MAX-KOUgpXuj=$__Q1Q$PC!MriEj z2`H|R%<&l9yd{(vCqw_EkkWV56xRz+#*Vr%=|O=~w7sO9%|UpC 8@3SwJIT^~IwKO6^x3a9fFv<;-Og(~anFf$b> Y01 za5tUoWnD;@5m3Mw?Xl_AnY7BeC^ORxN>Y0;$>^fR$_+PCMx1H0^YS%QSm555?ZtAw ze*MNCaT?Vjhsj2^JN bX2>7_&D=%V+iszMNf#E~fTCl2|RH zYL8b?!zJh}SCC>U|9Sn}(I>9P@(a{1NUGkk+wI-(+>d20Jtj)BwcO{r7WKG?NR6;w z6B0rYd+7cEpj<>WeuJ8`%>2b&SVz@4q)T}uc0lY_F9bw3K4ob5YJ9TxkWu839{bEo zyP<|k9AM>dd(l$IQpqBwh9n>!D^Ev*{Cl{La1ZT%bxKJGW~XD|BiC^qRqK2+^H3`W zaxzEDGLdmEI9axP l 72G`r;{*KQF5JAbPQS5YtN3~mYyC5(jn=*!IU9c){yME zN`-~&D=B580^y#so$%fM375#g7<+Wy>1I3xG%mB#Ch|Ja_k`oDvCt)K#h}8uKHrKz z^uin~_M1ZTx@a(Za3Pzcm1j~zOZt4Ow)yC9okD-e6 tYTA&^pI4I`NU5frM*z7%u5@<*lbC2b%2psxIIjWwC+S+qM0;(_3>1Se1ahJjDY z8jD7gj+#R5I8QpnYfZA{b)Y+`DQgcnM{~&B^Bl+c9wCr{V+Fjuzt%tL?R26ipy~eF zXKUCdF&uvH9U}fB&6@0Yt+gBR(}PCS;tj8yn>M_|uBFLRKYrK4%6;z3Duf1KSiPb= zOxA-D7{RO`fRr^XYp|Zd>-yc+MMW3(!8n(sCFkoBomF@}$Ca)SmlDjMWCN)Mv9;rd z-q7fE097E8tVCEv3BuD*cB_t4nkM^u#RU$o1a$%*rU!-k^5#YEr8X|gzbXuz 7g*J>Z z9V)~Ay5b7L28Pcbv$DDpd-Lf*UR2K@e8uN1Uv)d<1NaC7kXZ-_#u1^;yDP6}UMa4S z_D2JAo&wg5WWRkFZ)|RE9 6){MNsd$_o9Z!(&Tb#fX9)RSa*^f? zYT@;vlmi3e!_$h6b}$$xJ|~#F&<8 R{66oG{$%BhL#t_z`Xy4psK@0FbMt=>7K~}_#wBM$?!684o9b|>j~pRBEox6 ze79cKUcu<(n-8RO;`}@~PC$t?Hm&(>`=v`TR_!LSGZB~U>-TgCj;usrBg8LFB770* z|HJ$pmE)}VOst07*G^$>sK|^Cuu2hE-Mv6h{kZnUC3tW5Gus}So5a$w`-|;Eu}*Qu z>fx5MmzIiHx+$9dBM@Tm)r ~t!X`Hs)h+(s@(x>x704LUC;$AEWqR*%0mY=m3t!&Y zZ6aeDhOT0m=zGjkaA{G66h3f))@~vR@U=eYu;Ei4hOT2A-;*~;xtjJ6TAr?;A!Htx zboRA&&!}6Xyg;zw>YTD*AccXhKdqhykRP`iQqd5aS`Eo9_*jme{%w)^1^B!|O1&HK zTLZ45#-Y>=HCB=sT7Ie&1vB+6OU&wva5q*!Em%sP%=xFR)S;+PXbCVgT{R~M)g`%# zCCV|(6ZJ!LWR@C{Qs;#^ML+(EY61FFjZ)QdF}7SmL4((&sOV9Plht2{WR`T?66@lb zL-ouKh#rhTizc0CmUiUH@FN}Mz>q!kJOQQqbPr34a20Zt=>0KJ0EQ^gUkfPCet{ps zkPKN5E>#>d!4MVt=qoDHFR%p+A (~>9e=> W6&u%QM)2=OxcLV9qR;c`^K#sYxB9LH3nGxr?GyT842oL zcdXQA?fFzO`|$z(0&zR+@+Zdy FZ+G_QNV751`8l+8O+o{|K+mX+(3-VxmPojrx;2usx~8|%mR0cVTeiI()PBZj z_Zy>eLwO?xIXDo*xOXbbt<`bo3u8m1ywSX8uv>M-x(zwlsk%ZKVCabCH#8LrnnW<( z1UPc4(~RL8Q3MU?F#Kj4)szT`n~?tdiwfglKG8#??TkebaYWq?VG%t)8k>^X?#TN~ z*T_JeQ^hlooB~_TX+u4Ml-b;ozxJ#g9%ofn3E;sK $o70cde6uJLB{0TDm3q4;vR~GbxN~^M7~3LJasFhAV$dS=_`Mf~^jgzM z+vQr4;`LP~R^$lKk^T_p?<>M(FW1ni6Y2(d#^hwjDyXvoWaU<$kR^CzuN^%41 |Tqt3%eqoTg1vSP%AP}xecIZ zRZQ#ZiwBXFi9lQX24U3+@igf<@gIj%xH&Q>6joKeGc^8xFxuLTpNrJi5zq8a@w6}D M>g?uJbq16CKU$`}w*UYD literal 0 HcmV?d00001 diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt new file mode 100644 index 000000000..ccb5bc40a --- /dev/null +++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/Ganma.kt @@ -0,0 +1,126 @@ +package eu.kanade.tachiyomi.extension.ja.ganma + +import androidx.preference.EditTextPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservable +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +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.online.HttpSource +import kotlinx.serialization.json.decodeFromStream +import okhttp3.Request +import okhttp3.Response +import rx.Observable + +open class Ganma : HttpSource(), ConfigurableSource { + override val id = sourceId + override val name = sourceName + override val lang = sourceLang + override val versionId = sourceVersionId + override val baseUrl = "https://ganma.jp" + override val supportsLatest = true + + override fun headersBuilder() = super.headersBuilder().add("X-From", baseUrl) + + override fun popularMangaRequest(page: Int) = + when (page) { + 1 -> GET("$baseUrl/api/1.0/ranking", headers) + else -> GET("$baseUrl/api/1.1/ranking?flag=Finish", headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + val list: List = response.parseAs() + return MangasPage(list.map { it.toSManga() }, false) + } + + override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/2.2/top", headers) + + override fun latestUpdatesParse(response: Response): MangasPage { + val list = response.parseAs ().boxes.flatMap { it.panels } + .filter { it.newestStoryItem != null } + .sortedByDescending { it.newestStoryItem!!.release } + return MangasPage(list.map { it.toSManga() }, false) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + val pageNumber = when (filters.size) { + 0 -> 1 + else -> (filters[0] as TypeFilter).state + 1 + } + return fetchPopularManga(pageNumber).map { mangasPage -> + MangasPage(mangasPage.mangas.filter { it.title.contains(query) }, false) + } + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request = + throw UnsupportedOperationException("Not used.") + + override fun searchMangaParse(response: Response): MangasPage = + throw UnsupportedOperationException("Not used.") + + // navigate Webview to web page + override fun mangaDetailsRequest(manga: SManga) = + GET("$baseUrl/${manga.url.alias()}", headers) + + protected open fun realMangaDetailsRequest(manga: SManga) = + GET("$baseUrl/api/1.0/magazines/web/${manga.url.alias()}", headers) + + override fun chapterListRequest(manga: SManga) = realMangaDetailsRequest(manga) + + override fun fetchMangaDetails(manga: SManga): Observable = + client.newCall(realMangaDetailsRequest(manga)).asObservableSuccess() + .map { mangaDetailsParse(it).apply { initialized = true } } + + override fun mangaDetailsParse(response: Response): SManga = + response.parseAs ().toSMangaDetails() + + protected open fun List .sortedDescending() = this.asReversed() + + override fun chapterListParse(response: Response): List = + response.parseAs ().getSChapterList().sortedDescending() + + override fun fetchPageList(chapter: SChapter): Observable > = + client.newCall(pageListRequest(chapter)).asObservable() + .map { pageListParse(chapter, it) } + + override fun pageListRequest(chapter: SChapter) = + GET("$baseUrl/api/1.0/magazines/web/${chapter.url.alias()}", headers) + + protected open fun pageListParse(chapter: SChapter, response: Response): List
{ + val manga: Magazine = response.parseAs() + val chapterId = chapter.url.substringAfter('/') + return manga.items.find { it.id == chapterId }!!.toPageList() + } + + final override fun pageListParse(response: Response): List = + throw UnsupportedOperationException("Not used.") + + override fun imageUrlParse(response: Response): String = + throw UnsupportedOperationException("Not used.") + + protected open class TypeFilter : Filter.Select ("Type", arrayOf("Popular", "Completed")) + + override fun getFilterList() = FilterList(TypeFilter()) + + protected inline fun Response.parseAs(): T = use { + json.decodeFromStream >(it.body!!.byteStream()).root + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + EditTextPreference(screen.context).apply { + key = METADATA_PREF + title = "Metadata (Debug)" + setDefaultValue("") + setOnPreferenceChangeListener { _, newValue -> + preferences.edit().putString(METADATA_PREF, newValue as String).apply() + true + } + }.let { screen.addPreference(it) } + } +} diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaApp.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaApp.kt new file mode 100644 index 000000000..b11ca6dcb --- /dev/null +++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaApp.kt @@ -0,0 +1,135 @@ +package eu.kanade.tachiyomi.extension.ja.ganma + +import android.widget.Toast +import androidx.preference.PreferenceScreen +import androidx.preference.SwitchPreferenceCompat +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import okhttp3.Cookie +import okhttp3.CookieJar +import okhttp3.FormBody +import okhttp3.Headers +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response + +class GanmaApp(private val metadata: Metadata) : Ganma() { + + override val client = network.client.newBuilder() + .cookieJar(Cookies(metadata.baseUrl.toHttpUrl().host, metadata.cookieName)) + .build() + + private val appHeaders: Headers = Headers.Builder().apply { + add("User-Agent", metadata.userAgent) + add("X-From", metadata.baseUrl) + }.build() + + override fun chapterListRequest(manga: SManga): Request { + checkSession() + return GET(metadata.baseUrl + String.format(metadata.magazineUrl, manga.url.mangaId()), appHeaders) + } + + override fun List .sortedDescending() = this + + override fun pageListRequest(chapter: SChapter): Request { + checkSession() + val (mangaId, chapterId) = chapter.url.chapterDir() + return GET(metadata.baseUrl + String.format(metadata.storyUrl, mangaId, chapterId), appHeaders) + } + + override fun pageListParse(chapter: SChapter, response: Response): List = + try { + response.parseAs ().toPageList() + } catch (e: Exception) { + throw Exception("Chapter not available!") + } + + private fun checkSession() { + val expiration = preferences.getLong(SESSION_EXPIRATION_PREF, 0) + if (System.currentTimeMillis() + 60 * 1000 <= expiration) return // at least 1 minute + var field1 = preferences.getString(TOKEN_FIELD1_PREF, "")!! + var field2 = preferences.getString(TOKEN_FIELD2_PREF, "")!! + if (field1.isEmpty() || field2.isEmpty()) { + val response = client.newCall(POST(metadata.baseUrl + metadata.tokenUrl, appHeaders)).execute() + val token: JsonObject = response.parseAs() + field1 = token[metadata.tokenField1]!!.jsonPrimitive.content + field2 = token[metadata.tokenField2]!!.jsonPrimitive.content + } + val requestBody = FormBody.Builder().apply { + add(metadata.tokenField1, field1) + add(metadata.tokenField2, field2) + }.build() + val response = client.newCall(POST(metadata.baseUrl + metadata.sessionUrl, appHeaders, requestBody)).execute() + val session: Session = response.parseAs() + preferences.edit().apply { + putString(TOKEN_FIELD1_PREF, field1) + putString(TOKEN_FIELD2_PREF, field2) + putLong(SESSION_EXPIRATION_PREF, session.expire) + }.apply() + } + + private fun clearSession(clearToken: Boolean) { + preferences.edit().apply { + putString(SESSION_PREF, "") + putLong(SESSION_EXPIRATION_PREF, 0) + if (clearToken) { + putString(TOKEN_FIELD1_PREF, "") + putString(TOKEN_FIELD2_PREF, "") + } + }.apply() + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + super.setupPreferenceScreen(screen) + SwitchPreferenceCompat(screen.context).apply { + title = "Clear session" + setOnPreferenceClickListener { + clearSession(clearToken = false) + Toast.makeText(screen.context, "Session cleared", Toast.LENGTH_SHORT).show() + false + } + }.let { screen.addPreference(it) } + SwitchPreferenceCompat(screen.context).apply { + title = "Clear token" + setOnPreferenceClickListener { + clearSession(clearToken = true) + Toast.makeText(screen.context, "Token cleared", Toast.LENGTH_SHORT).show() + false + } + }.let { screen.addPreference(it) } + } + + class Cookies(private val host: String, private val name: String) : CookieJar { + override fun loadForRequest(url: HttpUrl): List { + if (url.host != host) return emptyList() + val cookie = Cookie.Builder().apply { + name(name) + value(preferences.getString(SESSION_PREF, "")!!) + domain(host) + }.build() + return listOf(cookie) + } + + override fun saveFromResponse(url: HttpUrl, cookies: List ) { + if (url.host != host) return + for (cookie in cookies) { + if (cookie.name == name) { + preferences.edit().putString(SESSION_PREF, cookie.value).apply() + } + } + } + } + + companion object { + private const val TOKEN_FIELD1_PREF = "TOKEN_FIELD1" + private const val TOKEN_FIELD2_PREF = "TOKEN_FIELD2" + private const val SESSION_PREF = "SESSION" + private const val SESSION_EXPIRATION_PREF = "SESSION_EXPIRATION" + } +} diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt new file mode 100644 index 000000000..31aa51073 --- /dev/null +++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaDto.kt @@ -0,0 +1,195 @@ +package eu.kanade.tachiyomi.extension.ja.ganma + +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable + +@Serializable +data class Result (val root: T) + +// Manga +@Serializable +data class Magazine( + val id: String, + val alias: String? = null, + val title: String, + val description: String? = null, + val squareImage: File? = null, +// val squareWithLogoImage: File? = null, + val author: Author? = null, + val newestStoryItem: Story? = null, + val flags: Flags? = null, + val announcement: Announcement? = null, + val items: List = emptyList(), +) { + fun toSManga() = SManga.create().apply { + url = "${alias!!}#$id" + title = this@Magazine.title + thumbnail_url = squareImage!!.url + } + + fun toSMangaDetails() = toSManga().apply { + author = this@Magazine.author?.penName + val flagsText = flags?.toText() + description = generateDescription(flagsText) + status = when { + flags?.isFinish == true -> SManga.COMPLETED + !flagsText.isNullOrEmpty() -> SManga.ONGOING + else -> SManga.UNKNOWN + } + } + + private fun generateDescription(flagsText: String?): String { + val result = mutableListOf () + if (!flagsText.isNullOrEmpty()) result.add("Updates: $flagsText") + if (announcement != null) result.add("Announcement: ${announcement.text}") + if (description != null) result.add(description) + return result.joinToString("\n\n") + } + + fun getSChapterList() = items.map { + SChapter.create().apply { + url = "${alias!!}#$id/${it.id ?: it.storyId}" + val prefix = if (it.kind == "free") "" else "🔒 " + name = if (it.subtitle != null) "$prefix${it.title} ${it.subtitle}" else "$prefix${it.title}" + date_upload = it.releaseStart ?: -1 + } + } +} + +fun String.alias() = this.substringBefore('#') +fun String.mangaId() = this.substringAfter('#') +fun String.chapterDir(): Pair = + with(this.substringAfter('#')) { + // this == [mangaId-UUID]/[chapterId-UUID] + Pair(substring(0, 36), substring(37, 37 + 36)) + } + +// Chapter +@Serializable +data class Story( + val id: String? = null, + val storyId: String? = null, + val title: String, + val subtitle: String? = null, + val release: Long = 0, + val releaseStart: Long? = null, + val page: Directory? = null, + val afterwordImage: File? = null, + val kind: String? = null, +) { + fun toPageList(): List { + val result = page!!.toPageList() + if (afterwordImage != null) { + result.add(Page(result.size, imageUrl = afterwordImage.url)) + } + return result + } +} + +@Serializable +data class File(val url: String) + +@Serializable +data class Author(val penName: String? = null) + +@Serializable +data class Top(val boxes: List ) + +@Serializable +data class Box(val panels: List ) + +@Serializable +data class Flags( + val isMonday: Boolean = false, + val isTuesday: Boolean = false, + val isWednesday: Boolean = false, + val isThursday: Boolean = false, + val isFriday: Boolean = false, + val isSaturday: Boolean = false, + val isSunday: Boolean = false, + + val isWeekly: Boolean = false, + val isEveryOtherWeek: Boolean = false, + val isThreeConsecutiveWeeks: Boolean = false, + val isMonthly: Boolean = false, + + val isFinish: Boolean = false, +// val isMGAward: Boolean = false, +// val isNew: Boolean = false, +) { + fun toText(): String { + val result = mutableListOf () + val days = mutableListOf () + arrayOf(isWeekly, isEveryOtherWeek, isThreeConsecutiveWeeks, isMonthly) + .forEachIndexed { i, value -> if (value) result.add(weekText[i]) } + arrayOf(isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday) + .forEachIndexed { i, value -> if (value) days.add(dayText[i] + "s") } + if (days.size == 7) { + result.add("every day") + } else if (days.size != 0) { + days[0] = "on " + days[0] + result += days + } + return result.joinToString(", ") + } + + companion object { + private val weekText = arrayOf("every week", "every other week", "three weeks in a row", "every month") + private val dayText = arrayOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") + } +} + +@Serializable +data class Announcement(val text: String) + +@Serializable +data class Directory( + val baseUrl: String, + val token: String, + val files: List , +) { + fun toPageList(): MutableList = + files.mapIndexedTo(ArrayList(files.size + 1)) { i, file -> + Page(i, imageUrl = "$baseUrl$file?$token") + } +} + +@Serializable +data class AppStory(val pages: List ) { + fun toPageList(): List { + val result = ArrayList (pages.size) + pages.forEach { + if (it.imageURL != null) + result.add(Page(result.size, imageUrl = it.imageURL.url)) + else if (it.afterwordImageURL != null) + result.add(Page(result.size, imageUrl = it.afterwordImageURL.url)) + } + return result + } +} + +@Serializable +data class AppPage( + val imageURL: File? = null, + val afterwordImageURL: File? = null, +) + +// Please keep the data private to support the site, +// otherwise they might change their APIs. +@Serializable +data class Metadata( + val userAgent: String, + val baseUrl: String, + val tokenUrl: String, + val tokenField1: String, + val tokenField2: String, + val sessionUrl: String, + val cookieName: String, + val magazineUrl: String, + val storyUrl: String, +) + +@Serializable +data class Session(val expire: Long) diff --git a/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaFactory.kt b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaFactory.kt new file mode 100644 index 000000000..862c4afbe --- /dev/null +++ b/src/ja/ganma/src/eu/kanade/tachiyomi/extension/ja/ganma/GanmaFactory.kt @@ -0,0 +1,48 @@ +package eu.kanade.tachiyomi.extension.ja.ganma + +import android.app.Application +import android.content.SharedPreferences +import android.util.Base64 +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.security.MessageDigest + +// source ID needed before class construction +// generated by running main() below +const val sourceId = 8045942616403978870 +const val sourceName = "GANMA!" +const val sourceLang = "ja" +const val sourceVersionId = 1 // != extension version code +const val METADATA_PREF = "METADATA" + +val json: Json = Injekt.get() +val preferences: SharedPreferences = + Injekt.get ().getSharedPreferences("source_$sourceId", 0x0000) + +class GanmaFactory : SourceFactory { + override fun createSources(): List