From 4fe22bccf3030fd3e71389a5cf8ad1aed6d3a0ff Mon Sep 17 00:00:00 2001 From: Rushil Perera Date: Mon, 2 Sep 2024 13:02:05 -0400 Subject: [PATCH] feat: support sending "new title" alerts to devices --- bun.lockb | Bin 173705 -> 174133 bytes drizzle/0005_shiny_scarecrow.sql | 4 + drizzle/meta/0005_snapshot.json | 122 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + package.json | 1 + src/controllers/title/anilist.ts | 3 +- src/controllers/upcoming/index.ts | 10 ++ src/controllers/upcoming/titles/anilist.ts | 89 +++++++++++++ src/controllers/upcoming/titles/index.ts | 75 +++++++++++ src/index.ts | 6 + src/libs/fcm/sendFcmMessage.ts | 2 +- src/libs/qstash/verifyQstashHeader.ts | 24 ++++ src/models/kv.ts | 31 +++++ src/models/schema.ts | 7 +- src/types/env.d.ts | 5 +- .../title/mediaFragment.ts | 0 16 files changed, 381 insertions(+), 5 deletions(-) create mode 100644 drizzle/0005_shiny_scarecrow.sql create mode 100644 drizzle/meta/0005_snapshot.json create mode 100644 src/controllers/upcoming/index.ts create mode 100644 src/controllers/upcoming/titles/anilist.ts create mode 100644 src/controllers/upcoming/titles/index.ts create mode 100644 src/libs/qstash/verifyQstashHeader.ts create mode 100644 src/models/kv.ts rename src/{controllers => types}/title/mediaFragment.ts (100%) diff --git a/bun.lockb b/bun.lockb index 1bde39cbbe2ca06cba03a386b0ab7cee6339766d..502ec7e4b57a67f255cad5b6ad60f05e7468adc8 100755 GIT binary patch delta 29328 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 delta 29375 zcmeHwd3;UR`uAR2j%0^OLP+8y5_2L%=95Trj+uxclo(1x5)u-TsS-+(sv$I0U1d=< zRR^l3_A081wn}JPIw)02RZ&yTVtl{P9&mfp{_cA}_xJwuc7J^Lv!3sI*8Hrs*FJmO z*>u3Myvp&VK!4@)_OCo!_;uCY4ec{~_xZ<_x3dDwnw?^6AyEYY!N zUXqU-@X5R)U(fzblIlrP>DZk7bc)Kz8J|P4%e;;5C3k8`k_RGBLV7~(hinA-4@h^& z<&bWWFGALboC4_zX$R>9xw*C^)qz~8%XN?n^hJ=4kn8O=`2uvZFVSTVq>CiUrO7;w z`FLg_4b|}!YTp#{AS4yHlb12~Oo0+e%Y|$J`MfS0BS$JE|C#ix0<>)ovLHPbvMyvW zBo#VHmnAxXAEX(44s=waG&MiJT|r)Y`by4ZpH4l&;$LZ6dTwD#mLyrBP_7=3)J3nO zD%3S+kxN6!I8=%BcOXg6JSG=YQy_5A*&2^EKm>hHpRaI(iw6w zBc5TJ~UAgM)ZkYu>TSL>OFkW_&iy1ohnPw@hh_*391!ML+OQPT0Z{GwS31z z(kM^Nm(*hd?8ub|qnx@W2za7Jl+pPXn%h)CHi7){yMX?DTeNqog{}X~>R4lB<=bP;DG_1GO3s)8)jxl<{M-((@L! zmn1Z{G(CS*VP;mE)FVXe`Y=d%b*V2T^*}wHzZ0zKXCNv5JIMNwyL5g{F#4aIMd(B+ zBstMoNaB+qDPb2qJ{XcR@YZ#ENV2;gq}lxhNj|V2k}9$ZlHynBdS^%sd+EbSEk3m* zO3Tog7RJ=?+Cl4qKs{le-ts|^lmU$;^;8e&&d{SEsp|tFX-Jyu(i4)Vx3eyjV>P`G zq$~K4+K8YrDb6WO%|M3RtXc+kHqFrR?i4INDWkH|57_uSwVT9^>m*6ONY)#Yd@&G` zeAX2b6R-3}N3EWPw;Pj{GfH~Bhh{xb=iBzw+A;$=wS81xN@_YLMm2cK)fT6vpT{@ayO)ga zrPcUpNQ&(PNx5gF^NEkS? +#Gx zHvz+)pw)5*B<1(V(zDa^#-zg^*CQV3+LVq?$uG!7Pv@l<=4XyBmd^Ikd}noEZJoFQ z>5Pn)@mvRY<+h_PZ2ltZZ12miB;7C*zzN+hgyo{X(Pkwv?lDeceBn|hi{#xGY z`Kc*cDQW4cS($|x9m!#k)`)mWvVR4V7Uffr)Tj+e2Y)UpeG361gr3n%Na~H$jFhZ# z$f$7equy*cIxA&Nk#08*k}@0$Nfqe<*#Oc!RP+CbLo|Ph0#Bpir`z=&rs-ByVzoAE z8u7GN(*RvIgCxgl1X&l-QJ2Pr{thyriv9#yAM#5`sz_ZFFH8dW`03l$m1?EhM;GPHl>X&H@Y%!bXqF%l0F-$_1v;i znly%JzOGM%6}8-GSkCzL0-EHQ>6xkRMvtckl~BtzBO~g{b&%BJ*CC1jW5$P3bBa$d zN>4@Ir1wW_UH%p%rGFKY(oIMy$Y?h%z4%34p9V=SpO}%Ut_Yd=$N}klXJCetWBR~j zC<7y5F%pnZOyHgBxXWK9^PzQ`lnjM8P%dL9QrVJp9y$&9K}hP~G5Xc3u#hI1BrWmP zG8vPt$+eI)P6c^k5fncGg-~mbWohHK1O88irWX~Yk4FwOVMFE3%~8j>X)d#s8$m|J>k` zMySBQdsUo_u_I5!5UW?uk5GGZFnGK=#@Ec+*ju1uPgI(fla`W?iIDYbw8g+!1pc_5Mj+#+$lw?%NEwdB%9D!L8M^2D8|%%i#e3GD zubzMZK>Z>|Hj>AA+t^%Qj`J>FiL;3-O>8Wj$KgDQm*ecpD{=106(5_a zzMUi`awnf?Qz}9O)X)bAC8(iVCQ0h6hWaBEr-l|I)J+YYK&T6aSaYs4wXsilTvMBJ zr#5aiP-TT5^tP}_UWu4|t~9eLTkRz&1~Ikyye1aq2DHwa=4W9RUfGQ5r!-gdX>L;- z(ScY-OHDknxkZVA76eV=2b);Pu7yom53VaXJMN17u0czHR*NS#wXjvZ5*8P+0#F$e z<-wwOTuU3<#LHXSlq;AK6vOyDl-`dkt!(Ul9*1)aUap2KTiKNPSa7MvwYY0D3p>c; zT2t?pC?x>4=)rI%+x3U0U?r&pRywcyM zEQ8l*RY&p4DQICRM&<`wTTJelv@yJ*c?_>?V`E#n5@1tmVQK8HT2Mnq^6~&1+rcXX zY$g*fyH@TL5Y4*qxVAQ>5IPn_RC%7iMcD+68X)t;))wUoH0&Fx(dfhg9@oys_VV&} zHl+ooV_P)_6;}E|)9fhKY-m)rno2pK$0=(5o>*wKdeM+(L!(wPD$TSJS_hFljfL`# z-VXA4cRstl)zs1hDR@QuXwwvgdQwRF8X@W~wU*3{mj~Hc3a`X@30Hz`rdyt>qXkE^ z9=tr*#>#mm&R=jP#HM(8X)VMMx3#b`ULImIrD3in@`}(HI|TajijZiNT@&(Y3MD=Y zEk!6^<<21#r-nj&$h%>uOhhOOm6FtkZh&SrswiLb;f~=}#m^TlP)(@pw|RNEjk)m3 zaGNp=lUH*ba>kE%T!c-j+f0&r!CG}WWe~L9(CpN)c%3U2n|z}g?`5&FFkWtxAho)XN%xkL^b}0&$-R{~p;6`S_(4C5 ze5xhy)xpXd@bV5e)4*1eG>j)iMavsnamQ$@e61Dl72Vx6rnPp_z?!2TJ@|^~*7CB} zd|$LxIfzK|57Zr#rv;CTq46q@vB?Yl_`Vpca>37V1S&qr->5Im(+p@@V(OaLd3mf& z32Y-tqaRiG6=-3q=8C55g+^UzqRFAu572Ukt6)_a1dSTzpt|=f(5PS3*{Xc;$dc;d z0`KmMIGJX$G6EV|)>gFz(6kaL?kF^>A1qNq!*-gbwoWAQ%8oXr6r47YG+7QoLlV@1 zYHJRZq>j+)s9rM`8diFgb1>Q>?+WCOovo(*?IkIdS9FMGnY_HS%`^n799*t*v}q|q zJ$Oae7&`>ukDvlWjmUh2H0~pWV20RQ=q4>`PlUACc?iMN5qta**BUqHmu6TUD|_1HT~XXI&dO@>xHuadz{}%o$|CeUIlAgu^0^M&v6ofp9j!St zW@5O-v>aLxukedj4j`m0KD10V!VuC3NhD4Dv(XqvD$d3kS4$ymOxx7AdRyh30^_BvGHDgtV$<%0lAnRU5=DuXc!GzOoA-(mX6%9uhlfC6FGuY-)Pe%gi_Q{ zU#x@4YG@ro!_`ooE;L7B$42w=em3PD=%}jhp=Y3xzcTV=Q&V(7S6&eut)wHQ`7tdB z%b-zpFcOg#`TMTivAy#Oq z9br-W=vpnh;45pN)J&J4b>LNg(I&qhYQ->YSqQXc&Sa_;N5fgsBw9&8h*F!>4aTd`r~}kBQ2sWK?;B)g?p#T- zDbtbbXhf;A+H@Wo{GnAeYt1VWn-y)gqLc>r{{{~p(`|*7CV5<_0QKySTBXxb9 z0}WQCPE@XZs5kF51SZ&w(2%2BMn>8}>A)+(qm@d8sJ+;K zb=fH-9vUhbY+?6}A^163R1S?M88mDI7*~edXm2#!rj+64784p?k~kvL0ZO21hNV{itUq@g zVO81>kR&*3X+yP|6QBh_!`d8UQIY6$%AMZ3U-Q zy52xzz-jFr42{~L*4nfbS|n|BltTz%fh0$TYq}14Vwg>XpjmlB^JrxSLNsn_E0v4T zsCzNDqbb&vsZh14>Xz# z>XM~Ag!Z_ntwSUUixkZ|OpRh_)K-jFGmE@=2%nv1RoV^JI!)c8D%sG`BQT*#?1DxU zU7gzUgQ0wOx>XrEOlz;YN-3}C8m@py(CKL{uC}U8hDPO~)|gCNbxqrT+|n)8I2O#~ z#@LkH;l}u30gyKi=ljOsQVx;^9B!N&=`cd`IBidq2Tk)h8i)6w(OQW7p*?^`y@D-m zUZg{^)|nUzEUYi_@=Tj@0h|>E>Y}bhj?~r!t)s?63qza(T{e_2q0v0Xj?>R#a!5g; z+^Jc#@-#x^#+t*w3ynJ7QQa7vheo;M!iyaIM`>=V&L(9Dv=}uG`N^wCamQv>trT&}h6htr8k#ja>$oLi;qW;_9NL#6r_r?V1?r0EN0otHUN}owWk6 zM%+r{v&UPNkn~4Aj#BfWQN^@6tb;~1QcE@2VY%v{dcPH+N8WGx99kcBCb^AyQ~>fC z1dR&U?w%Gvd+eiM>T&9DDD^V5_Smb-aUnFyUA>|yZ$HwgSB^lV8KR~$war9%YRTgf zqTJCVn6+;~qgJT1)ATbmEPPn@y~b)ih4lb=^@sMjMzf*OJgKYBjNN0oV}aFl8)TR| z(rw3SE0Hz;>Ck8Zu;^fHSL$(EV;(@mI!H67z@j8%NfP{AcgfYz$g40On2dL!(b5I4 zYGN@3WTVo&Dl%Hhr4V>+5OzQ#r^ekBoVw0Xpy6tR`>sl8 zl#9BWn@l;>659A+nJvQIAM~DDuGkIyp2PPQSxp@#(B7-6DB4tlP!C=;DaHeL`ukxK?^S9wT?N^VCzM8;EX7otgy&-H5QtN$JqT%$ zbtf6zK!jk11g{~a#h!c=+h#IVnqns+q~)>`p}s2T{HziCG(uV~D-qJ{E+C}k7d*w_ zCLyHdw+A6DMZKxiSyaXlgtWA;Bc$0~HaI1+MB7^6cZ?1emdoR2*yPF*K6{2$F_lVE zxY|*48R`J7gQ_JaSWE@bGU>txA1j|}V>!HXrcK#hrrE#`BP~kkbH;WRQ_xfbZ8Wb6 zik445$M?;$Dx0QhbFvOkjIx+6Lc`WAD4KQV<>fZhtItc)cvW}c%50mGHeHeiqj+2e zhgjtO)A{V#R#t~sA}Vu+=KboW!1N}x&XiF39U-c*wxqS0iKT`obihiEP*?Dnb3qo< za%hpXWS9;k)Q?wScjPzASadPxu?3n{4zkF<%;K{vtfshfy)gC)S6;Nq zpO^DqFIt%cFMrX-y7I~wqf44d>Z2%}|2=6ByOyf8CP{Cr>;HjlMnA^VE=ytn)imWfR36Z8Mv488;CAW8Zm zl6=X_?(_zYbR3|ABvtn$Kvq8kbdV(d6e&1rlH}6A0aU#U0NG#E-+LTqq+uI_Z)MsIBWHNq5ktBP2!D!N~>E3`qqwfoudBMv?y{ zwW(4=#2*d+m-Lck)*Y03?xw*I{&03DpTi4QgE!UlceA{U1sSrTbJW05(i|7TJQuA)KB?( z#FLV!0-Q(|>hVuXs`x~mCrNW>vaXY)DN+hacF#dl)HGc_&*%b&<9RaRYZtWV-!5=r z5eDfkUe zbP+kG^G`}z(@ue>iu|g}bGqGMNgCKou%m|k&QLz3xq=hPt9rzBQgD!@;0;~>D@hsN zf*r|f-Tq0bfR`!qNm&AdY-{NePfBW8Z9U#zk0(j?4!ZtVlI#@St|m!QC3SG7fRk=W zk_vW#qzqhjo+Nc$BV8v+K@VLv*7=$wMS0;w`FZPhBvDR@p48lY6|*lO)f})pe3oK%Or1At|a5C$cNj^+`nHAW5tER9&A+{ZEEvkd)DL zdc>2G_-T6l^LqS~k}{m7^CT(1Il690Q1s@X3L{KXy$^emAR|Mk%R@^|RVC^F!M&E6 z1Es6smaE=B-Fy9g&xKy1;iG#kI!Mw;{(aB&Ki!-Ceb1#WAb;O;{p(F0&7>!8{z_;; zeUhj_Cn_8#nv8$nbN$u57WE{>|9#JeTdlwExoE=t)x8$E3&qm}d-7h3c+!dg`<_ew z`<}}e`ySf*G{fodd#?ZHJ=y>7_go|4QM63`uibOKU%aMkE1vyzJa_ooiSPb89uE** zze(a-pcQ@-j~B6bLCg5YiMRYV9S&7~1^r<5>wm32oN*PP`$? zF5^*$lX&D|C%)uxJe$VPK|2dA;Yd82&KDg?;tP*B@%zwb^0*(8c+Vf4_}U-h@qTYL zwA;{zAB|^o`0ArceAQ7W?(}0ktKdU^OyYxobmH5g&E?9mB<^s`iDw^+XY=@0Xj`Cp zACG68XC6mek2~=yX!E(}Pf2V6&&65rN}Lz+rYDlvB3_L1tNb9&i@E>FB=#CF!+8lm zg7Z=y{Bsgp#>;VD&QIdJf=8W7!fUJZa9+vJ;rs^g^h*+67hHt%Tl@;nZ}Yg*Nq7Z) z165G#Lk;D%3rZlWH|E1n(WN1z>s7JECM{lv>}qg}VrE@&rtR5jXFjdoSXvs3&W zw6o9>?!>dxe9;}W>kirl?F^5*3;(+d|GOK{&hct!x1kNc7thY~)%W0k_uzl`T7 zxd?_a5oHIHNIMWq>_F5K=ZH8yRzY_>QClD1*AiTv9A`TM~ zTMvYfD6a=%Ry`0GiD)XKoIyl7gIMAWqPaLn#91N|TtKuGi(Eh~bOCXnh}I&`6+};0 z5NlmQ_={>HZWA%QK8OIZx;}_i^+7l_0MSkiX#iqy0}$JZXfG5u5Dso2vfV%gi>*X# zA;P;Mh)|K)5JW~p5LHBk3(rO%JQ{(R(g=h_R1&e5h<5HEqC~Mfh$43o$B2j){vIIw z;83E%14OJiLd0PrVm(3FL^(W8%<=?rk%&$rsxgSj#vqn72GK>FBjPL(31$%8#3D0@ zg=P@By0dvVA}#imgO!A;Q}i!~l`$3nIf8L=_POg=bR`9!)_^X$m4qR1&e5h<43D3=zf6 zKom6tag2yz!oN8Pzvdt+nu8cFju3H}h}ae&l0|t75VKlC_LLjJR@>ROca$QibT_P z5XGVxLKL+_w#V8b+sVQ|5d5>EjKmaigv3-4+#aGtl#?hGCrOlvs33^v#5@wy#5oer zi%!81)5RhZGsG1VGeuko3h5bwLe_?$kaAH?#BCymhk}?RR)>OE6$-*B3`B((5{8s7 zij5@Z3MCxkC6Pj6p4dv_W#JkD!9^yCSHvz7^M$7cVu8pdAw(sF*lR(y?IMxwB2gR( z{#9|1#A4we1@W3FBe6srA+b~hcYs(X%1JC2CrPXjQPB{ui+LngigOU+Y&5b>h(We* zibXLX7RG?MPsH0IE*3=3SP*MtL976z1&yTFD5mv(pjA1=Gizw~5jwM?n5cyhKG zWP3)eC{19$v)-F?`>_48YL=16+REI;+l@3Qnl4A%h3M6XF8+{#cx)NJCTTbl%}%R zYh%3D#^2MJgHG77Ih&V^W5e88>gHk7Sd~qn;9$oIpU`g*c&@rzgnY_u-VYvX2XT4& zFBJ4lh>kol?^D*GgdXZsBD$-6ob?zAu6r^XxhbR(9 zk#0v1T!}$AfTNcfr~+T>96cVSXP%S?y-|UGk}uUB z$Al{J;B)qB_m>c!2heO92G9%|4vYYj0ead?Pm<}eeG|Y3pmz!!0m}1R;5#M`?O{!n z3WQ$-=8F4!n0pDiDY@k$fLw7g@EWiLm<`axrHNVxZ~|xoIs-0%D^MS30Js5-0C&Ix z@C0fD_P}9O{Rr>_a1{6v_yVY+cRAN0KofX9umP|E9RYeAOCI72GzFRg3InY8>?_~o@=q-u*Km))HXb8|77W7-gFx0aexC`6^ z?gRAOgF5dK&wyjlj{`peCxDXxP3=;k44@&Up{ChElVKC^0q`F1K9feM^Lsgz6#z|B znwB&TY1+L2Oay2Jq!n)xFagK~@_=L@1sDaS0%<@xK-0b>&`J33W6cNBTWqmFG++k2 zfW7EC2S_{M6vDp%r-5IAGr(Ek9B>@?2{-|K4bYRvkAU~YYGm!&6TvtD*EscIc%V48 zkGY#7ptcm%`#%2RH!qPK_O40%*yhw}XBI zo&$COy8v1VXiDq>XcBJ#{sFuNybVkSo~757rXnyM^~s0K1Tui3z+hki@Dwl*7z88% zy@3QE44_vrtARVfUEmh*GjI~v0qg{J0W*PFz+_-5PzLk?`qFAd%TEU&8i)j*MXTs_ zV*Kv~^>t$_bPvE2ptptS4WctB{0cA)*a$oiOb2FAI>F2={8 z&qjr5S2hl~1Fi|+1NZ{Mp^EiR8Hv!xuq=QDy&XbtiqQ2%25usbE*GbOUx3d5TD|Eq zKr8VY;9X!9Fb;Scpw)XK&=KeabQYVcSl^Och`J5@3S0s%>*22;S3iL{sLC*41VC#v zJzppVuEORT&<_}gxD#blYSAH51aw$YH=7K zKiVdVn_si~-X9?R58!>^6@d1p??Ju`%oi=cVM!$m5L^ow*OGO5cq5qrn}F>AEoxNc zAs`g^4)_{42pj;u0;+&LKo+nAp#J|H*bVFiX!h*_J^?-iKBIp60)hR&KA;lV3w#ND z1H=N~0^b9XKs(@9;Ah|@@Do5Y>_^}SfZBf)I1ZcuDDD*S3ve1BJBt4epw;^<4f}Zn zE&#s+mw~H5ci;-p4Y&{70;s^7z%}4HKpEWtDDykOZGaX-vcC)51E>ogl7I{X0)eK0 zFW?P$mEc6zoW_7VU=QO)kPQJhU^DavkPRSfLDC4)euDNDw7%2?y&l%m;H&{G4%<64+IKn<;W?IDHE2Xtg4oqIrKBb=QunmkE%x~h@56si7AV(Ld z8}5Gj+L5~zPs?mXP+&-42zDXD^(gaXZA1WxNRfP$h2m#fev~D!{^BHwoud7Z%rpET zYEvJ1Twnd%_r4vOqUI456c{F*LQGS{gvQQa^v31AKO-g-xd&kiizz>{F=pdaw_ms~ z*|BkHZnPakvhcuQT)MO>T0e>tlmEwZwDBSZ|grzJg)6{+-;?l@r|dJTr4n2x=J?7)+HgzNA}w zp6d(u#_jwNF~Na|3zA+C-pA1`i$vc#vL}ALOC)Wb7+E;tAH&Y<$ADcZXeL7l$oPP)8svFeFrR5EUm_ zfD&3)y(uqkEOwnhrS%L)3g?s1GKK$17GNrH!V)H$o?s1QS0YIMVtkla8vXHS7hlT# z5&6Pvs8z;?f2&>7$2|zET4eNDxa1+$P^Q76>J$s~jUqQNzWdwr&GG9F`_#6FaX6ZW zn4Y4+&v3CpBIReKNfnbJ%*MxrlgD^XIWuwTdbKq{WL_fHlieI){zWY!{uG3oxKR^C z8(%A~{O#sXS6^81?c;>OBA85r1*MShJBiV!SV;Iv7xg!f(tz()<{cdO9!-m2ZP5O_ zd1|{tjT);pNeDMSWIQr1wNA70fj_7PBdb5}r!Xl_EI}j8#m6;K3k*D95a%qt+^^NfT-^ZEtS@3b5#!(W(~hpYS5;FCmPi~KVl9%I zC&8d03`&!qo@>AIKua>fT);6`oP+^;L)<01wXnm4D{b7crE6L90bi(gwBeJsiuUJF zhtFXE-z$CQskWw;Urv8ft3xEl>kwj`5wrb^P2C+9bsy%&+E6+ir^OT`^)kLRIQhbu zs#*2VH+yVw&n(_M$IjxB>f+x}3H9U6$fUx_7C~!%n)nI^c#gB{A|7+f z=UJH9_`-BX^|PgRwoN@`M#D{w-z5^y!}v=v4#Mk%m)5%#-5%!7dHMQ9T5UrEgVDC@ zUSj!q<`Hgu)VlpMCtgwxHC_AI!uagcvsx z9~(>%O)sDlvqUrrt>SpNc584G#>7K+b1H1^U!{wK;y>K*{Y3BInTMD0rR%{zgb(jM@U}0~VF9P^x!^BKVIkk~7r$R+A^rLoUUt`|#*KNfM*I0<&%^tGWbslEpL+JC`53OjmDHd-r z$bp*g)D=!N;>KsbSGQW$tMZpkZ_AISrREa4XNZF9>^ZaX6zRlnsVrep)JrnEgWPCA za2BpNFhrgr0K(k7J#Kiw`ipvJci!}9tPf2%?yE#H*+q%Fvg~O#zM#D-=g_u;xlY;; zsP_YmVSG2-+pWw+3+LM(>x zTBFw{Yvk(>56#=!>9O6eU~%9U+G>1^{mg=bt1E0bw>`EvgR~9Ml38}|W+c0ID0&=o zCs;JOjkX#ec;9t;-l_)fJ#Ia=@D+)-QHwA!4#La$Kzu~+FFsiD@;@d$wtE;UmLsQd zgYzO8&|9`tlh+pYo`RtZ9?$}yMsp29sH~)FkuA*r*wh?_q zPBrX|Z?12ArTjoplgW7VXM}cLlXUlv5ZkMvIgSHijA$M3eyaTZ84l9?pSw)+>>H;(TE()t*!KRB1JkL|igMoa&Cf>cvJW6hK z#JxL;k-B6qY8+nXXUC?XU}~^yCv7|X<IHosZ zd=RtcVB_AYWqHYuW5yu{mx|KZ%9h`ESvD_KkFf)_SoFBZ#$fNY{vO7v#%-RuiPAS| z;@&+xR-Pe(@1qmuiTL~Ivt^>>K1+~KcNbsYhhMzjLt9uJmz@0JdHYkLYTv2rcw509 zpmU9H!pC0QxpALMcm@nC=pKxRP4s<$3*Njqt@qos?|JX`Ft@9^0s7DQ4E?jUhcAwO z;mz@g!6HpPi_(`qK&!+w4Vlnd}GSYUJUds}+4bPmk zS2lk1L*-9ux#|+OK0*Bc5SP{&^A$N%-jOihDu;L(-_;*D^1{PAFQwd6&2jCp#s}-8eR__V^m4Cw zHAcPvH9lV7GHi3jSHtTzlG%}dVmy<5@zTp&WEyUKZ9RQmzitH`b~M*(jy^TM%YJuB zbZfs*EA^#*_uz+D=&)`ZUy8q3#f8n%2Ae9iK1kX_xME>vLq!0s@5Yzs&s%pcYdv-A z1=s}z;_eYY42xu0PQY6*Yst>|qJ68?Ki(PCAa}N!pL(DDmN-fIY1a#RLw^xd8=bUH zw67(XnQ!&iwv_8L*L?RygOAZfbuW+0Ol`5h7LvJ()3xN#2;hoTmVqxQ&Yy(qI&Gh9qUCmqCBq{Rzo2Qm z5Bs@zF%C)by3%x$92#zXslQoVT>G4_4!)}9p!SpTE&p!|!$wcpx&9(zX#a}Bhl-2H z;V-+mX>Hll=-aYE@J%6faSdo^=i5#<`QD) zULS4za+vrSW0(2o7RH|k=+k4yb@wLqXz|5RP%r#(7bCMP!?dNs_|pQTXUzM}d}Dhd z($Hi@9UXLmxc4?fs0PB_TD<-^G$ zP?1BhN{CS;PbP~6kP)ws)GiAb3j8KT&1p$Lf9Ttqb%>!WmGW@jvXKQRnc6O_J3-Rs zk>Y_Od&r-S6dsOp2y+nej%cUxIrKj5KY87A-=}VR{V_7`q67wLC;b*=u3<_9Yp&E2 zZivxsKDS5Y?T+d%73y` zRt;+0DXTHqDfdCzns>^og}PIwU$PdvVn}M-5vz9kj#v%XxFc3$)E%)~Z8a=}cYWD2 zqQ-5pYOo9aPInKZ8r@TzecW!UTU9mYyI_%3U-s~-aa*ifU|YN#7Bz2+RSR`ntXCyW zTtrpOHSUd7<9ecL12|?&5ltdj3~YdP-}rMA3tUP&g$`fW4Feh!h#T`@$@l{l&p+I> z^sYxk+8B|G!E@Cur@C6#IG$LqgD{@jZA*XafdDUW*SFPnt1w$-+) zxLq}VWH3Ta{NMPiJX4)iNxqo5tvaw%d-#IlBV~pSGkClpNk=D!j zYi-V5x*v6py-5293q~<8T)J2)Ho(GsuT;BSG-~i`Z@*bRcgYO*jaY~Ah*6wsgbD8> z+}-5>$DlH;tcPWyw>#1`eNOwOsgv89%*N{nO-9c8jY~^W;*N#v7&3Oj1UWx;@(iUz z#4@dD+FuxdP2<*U&L?MV*@yp7L6yP|G$=__A*tE;`yL&(&Uxx?!?gDdgK+$`h~H>D z@nWnQLcUijd_83k(Z@p$^J@LP_LQgP?zaZCHjl-^Nx5P>C85eoJ!;=w uPbkP~H#UECPF{Aqw3LDr(aA?PZ@%4C-p9noPI8oJA1B*y-rHTyPWeB_K<~u> diff --git a/drizzle/0005_shiny_scarecrow.sql b/drizzle/0005_shiny_scarecrow.sql new file mode 100644 index 0000000..375ecf0 --- /dev/null +++ b/drizzle/0005_shiny_scarecrow.sql @@ -0,0 +1,4 @@ +CREATE TABLE `key_value` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL +); diff --git a/drizzle/meta/0005_snapshot.json b/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..dc0dcb2 --- /dev/null +++ b/drizzle/meta/0005_snapshot.json @@ -0,0 +1,122 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bca1f597-6db1-4bf8-ab6b-a95c10d3f6a7", + "prevId": "223cc621-0232-4499-973a-9013d134b1f9", + "tables": { + "device_tokens": { + "name": "device_tokens", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_connected_at": { + "name": "last_connected_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(CURRENT_TIMESTAMP)" + } + }, + "indexes": { + "device_tokens_token_unique": { + "name": "device_tokens_token_unique", + "columns": ["token"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "key_value": { + "name": "key_value", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "watch_status": { + "name": "watch_status", + "columns": { + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title_id": { + "name": "title_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "watch_status_device_id_device_tokens_device_id_fk": { + "name": "watch_status_device_id_device_tokens_device_id_fk", + "tableFrom": "watch_status", + "tableTo": "device_tokens", + "columnsFrom": ["device_id"], + "columnsTo": ["device_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "watch_status_device_id_title_id_pk": { + "columns": ["device_id", "title_id"], + "name": "watch_status_device_id_title_id_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2960175..bf9643f 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1718402777422, "tag": "0004_jittery_black_knight", "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1725293569918, + "tag": "0005_shiny_scarecrow", + "breakpoints": true } ] } diff --git a/package.json b/package.json index 7e56dd3..e9683e5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@hono/swagger-ui": "^0.2.2", "@hono/zod-openapi": "^0.12.0", "@libsql/client": "^0.6.2", + "@upstash/qstash": "^2.7.0", "drizzle-orm": "^0.31.2", "gql.tada": "^1.7.5", "graphql-request": "^7.0.1", diff --git a/src/controllers/title/anilist.ts b/src/controllers/title/anilist.ts index 172357c..5b5947d 100644 --- a/src/controllers/title/anilist.ts +++ b/src/controllers/title/anilist.ts @@ -2,8 +2,7 @@ import { graphql } from "gql.tada"; import { GraphQLClient } from "graphql-request"; import type { Title } from "~/types/title"; - -import { MediaFragment } from "./mediaFragment"; +import { MediaFragment } from "~/types/title/mediaFragment"; const GetTitleQuery = graphql( ` diff --git a/src/controllers/upcoming/index.ts b/src/controllers/upcoming/index.ts new file mode 100644 index 0000000..5d6f4cd --- /dev/null +++ b/src/controllers/upcoming/index.ts @@ -0,0 +1,10 @@ +import { Hono } from "hono"; + +const app = new Hono(); + +app.route( + "/", + await import("./titles").then((controller) => controller.default), +); + +export default app; diff --git a/src/controllers/upcoming/titles/anilist.ts b/src/controllers/upcoming/titles/anilist.ts new file mode 100644 index 0000000..662f753 --- /dev/null +++ b/src/controllers/upcoming/titles/anilist.ts @@ -0,0 +1,89 @@ +import { graphql } from "gql.tada"; +import { GraphQLClient } from "graphql-request"; +import { DateTime } from "luxon"; + +import { getValue, setValue } from "~/models/kv"; +import type { Env } from "~/types/env"; +import type { Title } from "~/types/title"; +import { MediaFragment } from "~/types/title/mediaFragment"; + +const GetUpcomingTitlesQuery = graphql( + ` + query GetUpcomingTitles( + $page: Int! + $airingAtLowerBound: Int! + $airingAtUpperBound: Int! + ) { + Page(page: $page) { + airingSchedules( + notYetAired: true + sort: TIME + airingAt_lesser: $airingAtUpperBound + airingAt_greater: $airingAtLowerBound + ) { + id + airingAt + timeUntilAiring + episode + media { + ...Media + } + } + pageInfo { + hasNextPage + } + } + } + `, + [MediaFragment], +); + +type AiringSchedule = { + media: Title; + episode: number; + timeUntilAiring: number; + airingAt: number; + id: number; +}; + +export async function getUpcomingTitlesFromAnilist(env: Env) { + const client = new GraphQLClient("https://graphql.anilist.co/"); + const lastCheckedScheduleAt = await getValue( + env, + "schedule_last_checked_at", + ).then((value) => (value ? Number(value) : DateTime.now().toUnixInteger())); + console.log(lastCheckedScheduleAt); + const twoDaysFromNow = DateTime.now().plus({ days: 2 }).toUnixInteger(); + + let currentPage = 1; + let scheduleList: AiringSchedule[] = []; + let shouldContinue = true; + + do { + const { Page } = await client.request(GetUpcomingTitlesQuery, { + page: currentPage++, + airingAtLowerBound: lastCheckedScheduleAt, + airingAtUpperBound: twoDaysFromNow, + }); + + const { airingSchedules, pageInfo } = Page!; + scheduleList = scheduleList.concat( + airingSchedules!.filter( + (schedule): schedule is AiringSchedule => !!schedule, + ), + ); + shouldContinue = pageInfo?.hasNextPage ?? false; + } while (shouldContinue); + + if (scheduleList.length === 0) { + return []; + } + + await setValue( + env, + "schedule_last_checked_at", + scheduleList[scheduleList.length - 1].airingAt.toString(), + ); + + return scheduleList; +} diff --git a/src/controllers/upcoming/titles/index.ts b/src/controllers/upcoming/titles/index.ts new file mode 100644 index 0000000..c4ee5d9 --- /dev/null +++ b/src/controllers/upcoming/titles/index.ts @@ -0,0 +1,75 @@ +import { Hono } from "hono"; +import { env } from "hono/adapter"; +import mapKeys from "lodash.mapkeys"; +import { DateTime } from "luxon"; + +import { Case, changeStringCase } from "~/libs/changeStringCase"; +import type { AdminSdkCredentials } from "~/libs/fcm/getGoogleAuthToken"; +import { sendFcmMessage } from "~/libs/fcm/sendFcmMessage"; +import { verifyQstashHeader } from "~/libs/qstash/verifyQstashHeader"; +import { readEnvVariable } from "~/libs/readEnvVariable"; +import type { Env } from "~/types/env"; +import { ErrorResponse, SuccessResponse } from "~/types/schema"; + +import { getUpcomingTitlesFromAnilist } from "./anilist"; + +const app = new Hono(); + +app.post("/titles", async (c) => { + if ( + !(await verifyQstashHeader( + env(c, "workerd"), + c.req.header("Upstash-Signature"), + await c.req.text(), + )) + ) { + return c.json(ErrorResponse, { status: 401 }); + } + + const titles = await getUpcomingTitlesFromAnilist( + env(c, "workerd"), + ); + + await Promise.all( + titles.map(async (title) => { + const titleName = + title.media.title?.userPreferred ?? + title.media.title?.english ?? + "Unknown Title"; + + return sendFcmMessage( + mapKeys( + readEnvVariable(c.env, "ADMIN_SDK_JSON"), + (_, key) => changeStringCase(key, Case.snake_case, Case.camelCase), + ) as unknown as AdminSdkCredentials, + { + topic: "newTitles", + data: { + type: "new_title", + aniListId: title.media.id.toString(), + title: titleName, + airingAt: title.airingAt.toString(), + }, + notification: { + title: "New Series Alert", + body: `${titleName} will be released on ${DateTime.fromSeconds(title.airingAt).toRelative({ unit: "days" })}`, + image: + title.media.coverImage?.medium ?? + title.media.coverImage?.large ?? + title.media.coverImage?.extraLarge ?? + undefined, + }, + android: { + notification: { + click_action: "HANDLE_FCM_NOTIFICATION", + }, + }, + }, + ); + }), + ); + + return c.json(SuccessResponse, 200); +}); + +export default app; diff --git a/src/index.ts b/src/index.ts index 243deaf..18ccf8d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -33,6 +33,12 @@ app.route( "/token", await import("~/controllers/token").then((controller) => controller.default), ); +app.route( + "/upcoming", + await import("~/controllers/upcoming").then( + (controller) => controller.default, + ), +); // The OpenAPI documentation will be available at /doc app.doc("/openapi.json", { diff --git a/src/libs/fcm/sendFcmMessage.ts b/src/libs/fcm/sendFcmMessage.ts index 24fbb2b..ee2d54f 100644 --- a/src/libs/fcm/sendFcmMessage.ts +++ b/src/libs/fcm/sendFcmMessage.ts @@ -56,7 +56,7 @@ export type FcmMessagePayload = { interface Notification { title: string; body: string; - image: string; + image?: string; } interface AndroidConfig { diff --git a/src/libs/qstash/verifyQstashHeader.ts b/src/libs/qstash/verifyQstashHeader.ts new file mode 100644 index 0000000..d61aa63 --- /dev/null +++ b/src/libs/qstash/verifyQstashHeader.ts @@ -0,0 +1,24 @@ +import { Receiver } from "@upstash/qstash"; + +import type { Env } from "~/types/env"; + +export function verifyQstashHeader( + env: Env, + signature: string | undefined, + body: string, +): Promise { + if (!signature) { + return Promise.resolve(false); + } + + const receiver = new Receiver({ + currentSigningKey: env.QSTASH_CURRENT_SIGNING_KEY, + nextSigningKey: env.QSTASH_NEXT_SIGNING_KEY, + }); + + return receiver.verify({ + body, + signature, + url: "https://aniplay-v2.rururu.workers.dev", + }); +} diff --git a/src/models/kv.ts b/src/models/kv.ts new file mode 100644 index 0000000..8ecc923 --- /dev/null +++ b/src/models/kv.ts @@ -0,0 +1,31 @@ +import { eq } from "drizzle-orm"; + +import type { Env } from "~/types/env"; + +import { getDb } from "./db"; +import { keyValueTable } from "./schema"; + +export type Key = (typeof keyValueTable.key.enumValues)[number]; + +export function getValue(env: Env, key: Key): Promise { + return getDb(env) + .select() + .from(keyValueTable) + .where(eq(keyValueTable.key, key)) + .then((results) => results[0]?.value); +} + +export function setValue(env: Env, key: Key, value: string) { + return getDb(env) + .insert(keyValueTable) + .values({ key, value }) + .onConflictDoUpdate({ set: { value }, target: [keyValueTable.key] }) + .run(); +} + +export function deleteValue(env: Env, key: Key) { + return getDb(env) + .delete(keyValueTable) + .where(eq(keyValueTable.key, key)) + .run(); +} diff --git a/src/models/schema.ts b/src/models/schema.ts index dd3cc09..b5f141f 100644 --- a/src/models/schema.ts +++ b/src/models/schema.ts @@ -29,4 +29,9 @@ export const watchStatusTable = sqliteTable( }), ); -export const tables = [watchStatusTable, deviceTokensTable]; +export const keyValueTable = sqliteTable("key_value", { + key: text("key", { enum: ["schedule_last_checked_at"] }).primaryKey(), + value: text("value").notNull(), +}); + +export const tables = [watchStatusTable, deviceTokensTable, keyValueTable]; diff --git a/src/types/env.d.ts b/src/types/env.d.ts index 39fdecc..202474c 100644 --- a/src/types/env.d.ts +++ b/src/types/env.d.ts @@ -1,4 +1,4 @@ -// Generated by Wrangler on Sat Jun 15 2024 05:15:32 GMT-0400 (Eastern Daylight Time) +// Generated by Wrangler on Mon Sep 02 2024 12:55:01 GMT-0400 (Eastern Daylight Time) // by running `wrangler types src/types/env.d.ts` import type { Env as HonoEnv } from "hono"; @@ -8,5 +8,8 @@ interface Env extends HonoEnv, Record { QSTASH_URL: string; ENABLE_ANIFY: string; ADMIN_SDK_JSON: string; + QSTASH_CURRENT_SIGNING_KEY: string; + QSTASH_NEXT_SIGNING_KEY: string; + QSTASH_TOKEN: string; TURSO_AUTH_TOKEN: string; } diff --git a/src/controllers/title/mediaFragment.ts b/src/types/title/mediaFragment.ts similarity index 100% rename from src/controllers/title/mediaFragment.ts rename to src/types/title/mediaFragment.ts