From 495841186c1779ee8d0a41c2c1f06afcff6cbf7e Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 22 Nov 2024 10:16:20 +0100 Subject: [PATCH] feat: Stitcher compatible with live streams (#122) * Added support for multiple tags in EXTINF segment group * Refactored manual interstitials to support CUE * Error early when KV is not available on CF worker * Remove unused env variables for test * Simplified filter * Change content-type * Made sessionId optional * Do not expose outUrl * Added error log in app --- bun.lockb | Bin 508728 -> 508760 bytes packages/api/src/env.ts | 9 +- .../src/routes/(dashboard)/_layout/player.tsx | 29 ++++- packages/artisan/src/env.ts | 5 +- packages/bolt/src/env.ts | 4 +- packages/stitcher/package.json | 2 +- packages/stitcher/runtime/local.ts | 7 +- .../stitcher/src/adapters/kv/cloudflare-kv.ts | 21 ++++ packages/stitcher/src/adapters/kv/index.ts | 15 +++ packages/stitcher/src/adapters/kv/redis.ts | 23 ++++ packages/stitcher/src/env.ts | 9 +- packages/stitcher/src/filters.ts | 36 +++---- packages/stitcher/src/interstitials.ts | 100 ++++++++--------- packages/stitcher/src/kv/cloudflare.ts | 14 --- packages/stitcher/src/kv/index.ts | 10 -- packages/stitcher/src/kv/redis.ts | 22 ---- packages/stitcher/src/lib/crypto.ts | 10 +- packages/stitcher/src/lib/group.ts | 6 +- packages/stitcher/src/lib/json.ts | 10 -- packages/stitcher/src/lib/url.ts | 2 +- packages/stitcher/src/parser/helpers.ts | 2 +- packages/stitcher/src/parser/index.ts | 2 +- packages/stitcher/src/parser/parse.ts | 45 ++++---- packages/stitcher/src/playlist.ts | 98 ++++++++++------- packages/stitcher/src/routes/session.ts | 101 +++++++++++------- packages/stitcher/src/session.ts | 33 +++--- packages/stitcher/src/vast.ts | 27 ++--- packages/stitcher/src/vmap.ts | 100 +++++++++-------- packages/stitcher/test/setup.ts | 2 - packages/stitcher/wrangler.toml | 8 +- 30 files changed, 394 insertions(+), 358 deletions(-) create mode 100644 packages/stitcher/src/adapters/kv/cloudflare-kv.ts create mode 100644 packages/stitcher/src/adapters/kv/index.ts create mode 100644 packages/stitcher/src/adapters/kv/redis.ts delete mode 100644 packages/stitcher/src/kv/cloudflare.ts delete mode 100644 packages/stitcher/src/kv/index.ts delete mode 100644 packages/stitcher/src/kv/redis.ts diff --git a/bun.lockb b/bun.lockb index 100acace61b15f618ca1622718ad972ab01f1ee0..2e389993d5d0e683de17fa9221ce998db9948142 100755 GIT binary patch delta 40490 zcmeIbd3a6N_dkBlO-|%cLyU=Q9%_go5phEhLt71k7-}AZh!6>4o)Wy37)o5MYHO^T zhpM)zqN>JFC8~W_wNximm1?V1#qYKDa5G5Sem>9d_kDhUeD&$G_S$=$z1LoQO?#h{ zecyS$@@LOiUeciIE&FWQwElM&?+m(k%nW%tJ-tW$1xq)*Vt;KVzPa!f)(eDXNs z7vN6-@2={@72Z(68h}O6Uf5fXGkkdcQKRZ7Cx19f;LTEF6DB9e#~4O*qG1Gp@F_qZ zU`SHZ$oN>pco~co297qt!00#>@b5|Yl_Hf>+Kn*`KeSF77n?W~watL@)AazE{Ynas zj;kM^I4;a^j+X2IADxtxU=#vg8gwHkCxa2g_zy-a2YhtQxZxPwC_hg2w;r$z>X!j> zzf*?C#$Y^m$`f|V=_E-rA}N_4F%AM}nh8pTyMUJfz7((`;P#1nwg?IEiY}sK+K<>x_=TDkQ{8_VYX`k;{OLzmR4a zH2^b#mj&DkSQ+r>d@uqgQzPff8O=ijGcY)5Vsh+Q!x#y?BJgg2Ja8kxs(>{Bnc>2K z%-|h7&+S(Lc?nJcvWQQ;Ap3hAkR_)jx)tp#sa{JU|LQ1wjRHRql`cDY1(4Ugpf#O| z4(gzN5Fj%Y3dnls56BE}cwP>01+X0OKLAq7XmWFvCl*PwNU7Q3Z6*~K*lXa zczF&?1^5G!qTCOh8D6R2BtT}M=1Y>HSm3OTQGh%^7$7TYB|z@K*eg;Jw_cWvSTA*! zoON0y9|#BJ4z8?}Yjy~bIbRFN1Enh5M-4D8dHiSt|HiMC4q~J`t9Y%HcI%})M#qnh z85f&ujGDqUn98fteqo)7@xvw?#S|U@$QsoI&+!7rB_s`I`mZ)fjX4X*>oMC)K7SN= zPPa5FJ~4iDOmgym)Z1)R-)xi*d6A=|6^U_c(H6dLJ0eSjw0W$p>)xJRC;{aK%15|wnKz`1p+G_&h z`DUqps^b1Ta)4`qO!zS%%l#lA53ohGFIV_%_wJIlQW|42#n9UP11bH9Npe{cXU0t) z9h>YpEZcRw${VMW29Fd1|?7d1<+5ap-C5>FV{-@|W_2zG`{th4fborx!>GI(0VW#tXI< zGh%%#^^uh852*~>TA3!#=F=u3q`1z=mz$bwG+^G0bu`IEDz{_(C*p5PRz}0}o z0NsFWne!BkSt~_412~Iz;W-JX0DJGTjgjV@4;Y zjIKW}d1BJok+EZk8sVQxL#PbMoEm^^2;Y1nJ3b6p1^D>Hq47iF4I}U;%439;oR7|8w6O-{Y{ta@+lqu(T z?V{xSa|LI()5=s%(Tbz(4KXfUmqH#tq`|28A!C!){2=jB zL&uMW_cNBetClO5((gwpLq9-Xk}m)E%L6}pQ!e4y*tl38t`6!0P+tL% zx%liSsbfnbiDJ9?C=2~ z=LrdbB>=O2ksTZXcl`%7{hJ9cc+ z*!r=P9s*~PHUVVwTkt@Jl?`}~&8rDuNx(qW&tAh6`9nX`6k(=4Ah)*!WL{S~Ocq`F z)Y#-9F$pn4V;7==MKKAG9e0AebwIh4m(UFx<1KGf7-x6j#eok2WI>Kd#`6$#CE(m` zKOa*Ft}bxy?P*2N2}VppvM~pE8Pq2OGCM9n_7-umiOG`_hZrN0lM+MG->yf7QP(iq zr_Ms5J_rT?)&Oj#;1hrifM0qjVFq9=;BG*s8wxqFl%EA;ud>x$wS2jhQN<)%dE>(W zA@fd2%|9Oe&%?p)qH-c@09lR|%9=tIeE``aZkLg8BHC*MUtG?F!Ny$RtTr)18Iae-PKq581ARDIS$2>C$i}o2 zkkx*4%-H1E`lFK91LtX!swz9+4Qc(6v6ELSx<#rz6_5v*0LTL`t!4^?9u^-rek>B_ zhSjC^_eHyt<&_MM*c|HBkn))e$TR!_kZ16Pf~oFx6>2q&e^RnF08g=+M**_6HdVd2 zFcn^|Q?=!C?E&OzjB}e6E2ng=D<`=hwLHcqK%Ugg3eE%M$@|w+CJLN&I|7gwJ*vJO ztu-J&7XrvURsv*h<}{G~_5eNmt(gr?;S~p0J6lU zgvxPl*Mj6zP|+nq62J_EW7Ao8E7Y?j8UeCMYbaQ&wbY~0yt~4`QovbV0?^KiUmTG2 zEUt~5v8C{n8PaW!h#wcv&kaqOV7Mz(exm7HEhR?;$OF9w$OES-e5!(@0eOI7fIL8d zcTDASDXpH7a#{$;PwS{LECFX!_@~3WmTMH`!0a}ER!+MXAZto#KwbwgKwgIypkppR ziWHiX8r)vW`9naK<5oaya#B)X=pa>U2`ZpcsXaPM6@3d2@&di0-~}uQyXpB@I9?c^ zPLhGK3UA(7beLKdkTt{xWV$`vF-p+W}cJS=`PVBz5_5(Y*%(W#50p!(ZLz+pZxU*z2#z-ok^|L@ zk*YZoklj=nKxVKn)|eUDI9PVH7?AO9L*&4df%8(_1xL^fMRrsj6-@Ay8mL;FB$xxpA}s>Q16CR?8ET+9-UFPK)(bdGFfCr@PRW2g@GUj) zMFkxrCH>72lKw&p3Ow*}MeuE7Il!a@InzCw^HEYFEVX8gljnQ%-8x_2kea1+*9gqosS8Ms5g3HEA&VK~t_E~`Z+{dc@ z{bW<@y|fi+tJ$U6D~y+B`e2-J{3)pmR70}~vg3H*Ebw+X#BjYA39k|vX| z!hC_W1@eSDx~t1Blw=r^BtV?e^>FvcCrwhNm5jS-r+!XTh_v*P|q*0$%O&z*vfqSO=GH*|CQ46fp9u?g(r?+?cwn47Lf+F*dBWP)7yAW`moxp{tZlI=9u9~J(45O>Aq4#T>b|KVopPr}e zEJcG+hP&+NrW(d`Xophwa`_$x=IL?i&$jP0DHm_@3pK+SV-CRc5|_OVSa)Dpe5mmw zZgZdB)!cWw?huo(tIUucitg=1V7%HA`w$p+ATYCvJMHxdYZ{_^FZZI?!>yYrb#S4j6x!9eyJHq!W>IUIi&$JxvX1d#d5aB)3Fj}~az7cMHf>Nw|(YA1V_$Vtk;3{;CkO@W@D^eeXO4CF8oGI>lPYdh;M}3?fxUB=j~31K`#Q<7!P34`?;(` zz@8QZc&F)g^NVrW?SW-a@(o}(Tv1alKi@Dq0&}>thP%vR?##C$?C(&=Q<3ZO^a8`^ zEU>KSVA#N7-KXCQH}APK-;VHYuuxjEm^QVFH*Ig!L^Fkb0i_}ONxS(XY06U6lYn_d zZGQ+%t|RkX;RTOd>*cZs0Amfpb7;E>Y^cOwdeMvJ(ux(gHvyA) z+uk2xt#TViB(ItIy*mwf>t*1_y=Z^9wHl?)LM3jZ6yq-XUUTo|hA~i7?LZ0QJ1DA> z+=~u3_g=yJ!5Z`dN=c#=OqzZPN~5@2yZDRn+aAU{1B-JH^mY5un3`D6uMvfgIjz-Gz^|wAZZFPRy^9%e>%DI}%}+ zT_@|jIn>x)fCb?(AGhCjmz@eM44C2e`wMRHXoTH&J?159qUXEp?ZBDRXH*ipinjLO0Kq(mYh|t4a z_FZ7Ci!wyCe@z-Z{1kdv3XJ|Evag_q$M+S~mAB}g zz>VD-7;Aqa=FMKNFduy3;<2uv_oQ$>l4+SaGC2 zTm#1XASLg=Q?C|B;COfDnF#CM*A1hu`}CP`tITf0=q*Z#DD@Df!zjttN^clOH&HhR zrT*@s=bL+@5HG4+dsuI|lNV9yFG|0m6emjk-V|;Ut%pz=DC(-brAsL&$tS)*Niq=n zwr*X9lB~OlQcr%?ekwy}GFdZR_B3GZe4z53!+e1#ErGO(z9S=o7t7l0=uZ12!W!}} zR?5BTlW=PjO3#SWO_ZJ$rO-mcHGAwa3%hmbDr+Y@_feNF~2g)UrMGN89&^M>Vlj-GTAoSSnIG0yi9@@Pw8V%Dd0ohIuNC&1l_Bi z(p66>@U(7C(j|KjN`oLBL%5JyAL#@EvNG4ZGry0penMRv_o4yeW{^AbYJ{2UPP-am zA3GzH4!B92IKZu5i}0O(R?6M8P}UCAba6*ThuaTOV)uds2n*acORfnVUMH6|7g$SP z6ZV18vPIG1)(Tnb5N@7xr~MFNR&!_m5Mgiq80IUc5S`+( z%=5x7+~Ia-lz8DWHRNz^V9kNS8((wTXMnLaBi3|qIqXk(pJ%oGgq4=LeIBJ2qB-ja zsP?Du86NB%V0}GUssF-1d9ZQ7`g^b}U_&%!Hg-q;6k$*LO!f>pKsVn4)(8}E!C>kR zFgg3ICe&Wu&#nZQMN7yN!Yt!+=nq|1tPlG%pBffWmvB*3tXgB*p)(BIx76U^VMw)g3CGJH$XZwBW!MeK4 zNVokCy;KDZ^9pzHAsB1YKv8kktL!oU2ekCs#bBx?kzK zWKU_or(|8xt(`n2LaCkT<&vjV^J`s~;3@4v>1jdtho{uy8(lX`m+VhaYAhz7KH62g z{9{{dJVugC40CdOdJUE;%+L81ZLdY>fzZNc|FT=h&R_ z2Hie^nqFv^Gq3WqOj4!q8wacl+K^QYaoI~!9ipy^Ot9%X^C}#uf~hfO%HIcL!>^ z>YAllcA=cOm|OJS9CPzM0E}nj>A^1gyWT9be`&2S$gQW#8V?LU0iON{O1x%Lf)9YP zJUxELH}VhFFCT)4aaW?IJ7`2t_Dx`oC59PSy{8ouhbnfo2TOOmd}jjV`Fr}ducLMPRi#6P9g0NWbo3q*+r%lr^n7zm}BHMy_z9aez}z?f1x! zk}KvAHg^mdcLvMH+S(7KHHf%twxi4^@c0f?4g(2x1y@~msfW@mMUb?kfU)`trmYK) z(3v}`Te$tXVTzr)d}fMiiUk#z^%<~d==2k2xTBJTP8Ks;TDu%3?14}8p71i&fj|Ik#MUMX( zur^|R`!8ny(xe%Y;SyFR%&dMsCTXY}1$~u=Ji`zCcEJnnDWa z#8v^D;c0Ug$%(B5Hrmr>KaumfWMEQ9SYvhp>yu4c)KrI4*lL&;D6$+R--OZt>?y?{ zH&DvG*nYD8O=PC1>_kQVO{Av9F>6^|lwG`#U94HcLof!#_L6oViqFcTm!GHDA-m|# zE?&$oRxjzH9hqI+lU=-z;xme~Ql8@6?BeO{VyV)m@sy>GNCqL4#$Xm@GB`_iWeBZeIa3@CNk!ZO>^X=~Vvm3&dazOcrpVG@dvPxFBt=#+ zBOGM|Xnqy5rQJ6`>p35jrvr<`gTn0x+ycfS!9<|<$r0>7r`_w${v6|QO*4TfchDSo=!)CP=W!8YyrjuP0QhT$KR5e7Zt}g2?un_m6BjI+P z%F=FZclvIZ{Ryy^=mAy)S5T@7G*Qfx<4X*%X25KUu7-I4V?&ikg&zZBcOWBCm8xd5+Sy zx)i(&!fu5L#;k9EMT*1V7B%FWW`~CrsA1tFwH@KI{s8ur*qk)4sTNGIzW^AE#1b2) zOkg}2nI4!=nkql!bEr^-S$yJej|Ij&3fs0f1LJ7}gP6Yt)`2lf=aj9MDPGt>=-4NZ zCcD0wVt-xR6j@g(v8icV#}sdaL{`U3+5nE?9aM&~i}$^a5(h~+H{Kc7HARMrh=hD| zDlnb`^rExN+)I%Sp%4`U^^#$!&3=@LN;8w}AoN_X9wh3HS`uzfqcpDg7!@68QIHvK zP9i%PHGAvhad*+4;r2b0SnCk);NV(NCYsHSWH$oU*9}Y~N9{;+% z^nM~eGV4(qS8nSdb25{%+Cpqz9i_oYnlTjl3}}v{vYqG+@0;ynQ66lR%kBYeDB2(e zyh_>&jCVMA{4JMVv6E*&EKnRUPTvHjHJ~#mm5buS?e|dXCZBoEW&3qeR1_U*y3GER z`7AiNhg#NC%nk?j?V`2Q=D98cCXFJ!5}x>mG9w|fK(gBdUz52@W)fq^bFmeM++^KVco z#6#&%gMqqaN1=B3b5eC+>iCdg1ErzTyg``^!pQCfFp(kw>^FO=3Am%DxEw`#(aBD5 z3S9|bYDL(~@$r&e*Ys}KN%Yoh4nJu=OPL@+CU18EI6{#CjzWECeiv|_)JKX81_UMd z?kn{VC-_k=uTgzX_ktcReNo9)0ULo*-UG(I(m`1qknpXOvOlajDNMcq$O6Xx*Fot; zFyUI1*%h2l?eFnzaCiqOvKzQ5F@WZG!)#(H6R3S)fV4`;FU4g$21lu&aV#yYji7!X1C=}?rdSPzW2SVvGk6?9k$pja8a2^kY>eip6i+AnLK8N{%Td7*-irz(Q0HDVNRJ<$<7nLQASU?o!ffsk20N&{-GO61{BKZ`NHMzJjwjlwi)Qsf}? z{~DzMSU)6j!=gc;sXrRJC6^q+IWojc@PZ~T+**!O1fA}SsW+L( z>62LH*_1X6J^qLqF*R{KRezFpV(fkF1fa@aEO1#{xRDk?jqPi?f&7|+uI6OTDg25x z4p<9{>HxakD5~@a$MR23A$(Zc(jGoVlk>UzCdO!y3+#O_ic!2sF;g*9D%uB~e1M`y z5v@|wm|W;u*X&|CiWrmC?jun=Jru=$(_zd)ad6Bs60`jo6>37GU3P;R%Bu>0%zB>y zg?XRS0PNx^l2l03?wAv^(ioGY9YZl(6m2tAic)3*V}P+f801$IB6?Gy$To0rT70I* zgTjgsml84JF;r&n-JX~Q68ECf;dXnJII)qr`ofINB=de@+CRlylF@yt8C1hlObp1F zwb$%6p=0MFH+yyZ+T7x^sd|?URn30UJEF=A>=N?pix>M0tJC9Ri{2%MTwWBsDJ;BL zp!r3&tE-k=i%cB5DDb1qsF)HF{Wn)x48;XhtToI21+tG9GAEAznKSI5J4Ktf&+)VVs8PfQSd&ypv zO>61~{2fn5%y6$H2TuLGX~{${U#rhf{6$m7x>;TaP3kup6|LXF-|H|{aWDER*~^=z zP4fyeTW9Q;=GD!ssQ%-*vLEBGF9=kNMOSN6@#Ivz*%40`GP%;$f47!Qhh}H@O zd7Uz?mdxcnLOKQEqtCX+$)1Opo zmEyJEU7~$yF?P^GjD6L@H^US>%qx)2Zt?Oq&t!~R=r!9kKcb%(d4*%_`Y(7T`IJnf9Soa=QimDqBu}eKSPAy$h2+_AgYjnmM3RgGLR4X+uq` z2~vQIHLVawjgeI1Nz1>9zV4xw{o9b8uLgQK%wW(Wxi<8L5MKoR@_bfMotxZ(|IpHI zBn`l`PJMO6z0aEVS^3(4E7{M+Q2LWrfCGdLYFYkt=RbRvCQ%j$?+FSxcf)2S~SxckC?>-_N1E5BiGAq|>@g*FIUN~de%PltBV z)!J5wd61lSfSsn$I@TFSq0!`17o9mrQ?t5ON9U6`ge(f7Z+-i(&bwL$KJYdNsJVsE zy1G`BqtY0Z_oBHVthdCp-K@f{a{~hpmoqpPKZuJ#IwP1JHj#>nw zi}K@WVW8!&CgM-;1zL@r`WA|Vai70@W^YJ-{qF)Ej_kOc=m+N{_Nas8v&lwgp8rl5|WSN%tAdqUaz&x4QvA40Q;# z0_gioRvFsW(6Y>TX*$=Rr`_VV2*yJf*!f9YT42Hv>?Nl`w%cREKPNR^6 z(2qe@V^paY0rGh;2bXwDh}o+rln`E{q?U4w0~Fi_GrT}%e^fN}%yUMY04w92X@s`b0?o~NXQO{+w z!h(_Oq$Da=S1E%Ko?dq_%*&5Fw{sKBQIRMO=}xP-Qjv(Y%A*cyS+xx0itrlfg|=o> zl>u0H*|DSKY`X0N+JY*!0MP-;>H?5^IFDZXHA7onI@HlKGk>UG@m8MR#k6wGEqA{{ ztbg0RV%#6YJl)KSs^Bw6YTgHLyqLbR_Uf-4W~lOFmFCdSX5c)GT6Y5jQFMj5i>3Pj z4n50B)G*u%2-R0>gdME-Q|%$N-(kYHxSGP;_2nDegFB@h|LiLKfNr@=)A6XodY;zy z#4t5zH&C+!onjD6U-F=Gk(~PKk4{JI8$GX9?3vxk28wPEx(~>&J3ww9aD}RNK;<7a z5N!_sxpcNU9Fx9wq*d(EGks$|EtlPwzENd+gSmH}?dr2psW8-E3fZY|F1dT8%d!dm z_6<-3!9m7MissebN+TJ(O9y%ZoS-#~U7;%&zkv5(8D~M35&uU(izL7L+s3?sr#Fk=`z>mRK@`FXKLHZ3i%u7C2iQL@3Q%F za>*MJ!jIK_oGh&V&zzS*(qwQawMha0iuSLE5QL`=7= zH(mg$E*_&$UQ_xpD&H19&@-rDA(!zf510P{Oir`fTAg$c(m(ypWOK5|Pv`U@LcV2% zN2Rq-fh~`>Q$A5}Cu50r+iEhNO3|l^KFS-JuT$%1ux_WQF9RhOx%?soYIO>yuIhd*ttu)D^-SKsRh?p0+o#U;J}051HQ<1Dw1dAMCql5 znCUAt{aN(gn#?Y6%L1@7E!R&*&GL~JN6n~6D@ya9iyQ5%l_yaCT$zIk_2=4Kw}C8E#YmBN=un?mITVeOd@L13FmmJ@5Is-INazV?x#L*wFr@^aZ+vq8 zQxwwCMTQlJ3LfVFZ$gC1cXJEt|4J!OAXNqxOZEFg`*YcqQ{O-KOY-#=k<07YUSbnQ=HGP3gymBGXW(zN2jD(iV;{9`ye%uc5%TA`@4+zBUK# z)ADVLqOAUpS%V($|LVWe|Hqu{<5Jc#GjsVs;Oy&MI5$9xNgVyA)*RHXR_#;XDu(tEc&Gw77mNo~t8iX`K+*m>B zan{R@vg>KdGOG;dhV`l4a3mT{XzFn6f7-2=H%t8xJ!EQ^1+}q1LDx80z`@+B)G;3W z_<&dG;c(d3lT3A5}S9CSr8t5p#k)9o4RSDIX?5*Cq^hVh-=jMPu4Cf=@PhZ2gdGf=gkhRX%IAQ@u zI6N_uXw?WSpw!Y$@)Dvy{+v4egI>#CbC^>pbA(m3T?QJ8ajM+=@CU1lueBWJdeoFb zjlQa{$Hza`n37bYw-}APG`Piubky=Hb$=v5b*cXmmqiQ%lV>T`st@uYqK6^6Z}ek zi7<&0yQp3watnRC-=3P@{e!N}DV0qaMFT+?s;>iFu=v3%8P)xM$R^Mi2L3T%<&4I! zoP3nsvYU3`QK!CP&{b?YewGKQn+I_fxh;z_}5v%_5M2bS9Z%PnvO^DEgB`^L{MK#cj?l>RofaJ zXzFcZsT#ASm($NHtQ+p8ubJ26sK#BsQD)W10y9^BTPjP(fe9SG3$y z*H`bHa6FsNZ!eu44LhnyRXv@%=;suxR3SNP2Qnw>Q3ueWR8PY=iXMXz7SKolr@rSY zVbJ1+Jvd2b&+(68B~?3SNs-6Z|I z_q)*!t9HtMFt*0RQLgTDBygW`CXN8-CXxlh&t6yV$b<~&{ zwX*3FhZ%oVdbk;M83dt^du5n(6J|+YGgya;U3>qoe|e0E!*br5yQ%(o^sVnDEHWW3 z<&(<~13}QF0l4OkVhjk(eKeKnKEa3;;DrA8sBqnto!M?obt zHNd<`&WQ!Bx1f`DB)Q4gfCQDkck%<{r*=Ws5DAS zodkCBwL(E9tcW89jJ7nLnPgc`ec@%7bD>W*4g9fvwrWnKTa%z;|M^I&+={n5yz1*Z z@;eA%)S(SR41GuDt});J^v6>-{}MVD#!D?9#dkJ6C&QWPD=ojMezWu0rfaa>Mf?@2 zwvc|AY#oQz@0)_KHiW*Mf;H1OT2`-iCDsfYx=oOaC^Me?rb07QC=kG@Z^jI0QP|O> z(B#6bnPPNf5)EX6e8UyASB0wp+CJ6ll%nsyY>`>>ziro~5Aim;D; z!LA0uO^r;tiD#Yp8LA`@hRRVVJOyJ2gVfJnq$Jek>xSjC!YnoQK{~+H`5B?47DmX? zrwCuLCycF>uWW+JwMs3(8PVh2nO0$E0`SsU)!9Kcww&m9uU~C;y)YS6(dC&KMBfcK{LgEz2X7lx0|Z!C zextON&TPQZWxZKAl6sHY%|b-TqFu9~8`tQ;EIg|36Rm#o*+>qJ+rMqd;L|Z!FOogS5|3%VMO|B(rcUN7ytBApV1Dyw=VS`D-ExdR{h5c zG{2?H{~!b{CuNL` zm`orYScp;dEv)gGr+r4B8}z!vgu~}S@K-HKAPCjhl2*FRVVR z`W~)bfAR7a*>ufl7YIW2wXK!*PEY=8RTJztbw~PQ*QWQEO?^M$7(QdrqS{3dc!)r{ zvK%H7w8R=z#1Eed@lF%h%+jvqRyiC4r);&#c?DrVcX|nkpQfVA0J@Wz4lsnA48)|% zSiF@_rp~Ad)z{A&!`?e`bIBOITo4~=V1e{Sx4oxqIPhDUOFPlxYS4%u-Ak*|A;Fdu z+|;McilvsfQ{Sa~tU{@WtF9OKm$aeS`1hmAOEFA>L*DHbvUJFlL#x(KcbEfF!xPZA z>)r^DiOFo#$;P8ddK)wjHWtz-JZi3_xl666()ya-q!-T=pE0i^l`y@$=-N``{&I>= zeIf6F3%46|T3bFXn+LU$LVV>caWya1XPL<-X@lEp?8Sl-XMgBbyOzc@<1v~XM$#34P^!bw1)$iy@HZr2=(F$m`zBu>EDrH@jr}g+!5;Sh$ zGS-lD8|Js0q6pFGy^P6Zd70G<7nAqKRRnwj3q9kV@yRE`K52x^2A}q@52!YHp#6Ja~SY zj--=Y2{m}WdkHb1+(h__GV9O4m$lz0V+COyZ&Uiq2zfqbY5U8Te?@(Ra>exai9^=9 zCuXMs{O}b#EVPGv5e2S+Db#-jEH9?143t*p!f59!2=QnXw+S9t`0F#JZ%say-T5{8 z<`p=by$Uys{Fsi;;$u==`_uZ+rc^(Zo74vH~x!Dd0HC9vBYCN@_Zh#K4pz<1sKJOqUIM1HcU=n`N zg1W7N4sWM*YtXH}-Feug5iJ)yif;)UfueJ`YfHD#QK-Ha`oFC#kdow^*U6hQeOb4zSG&i zQk%ZjX4ZKNeKeNQb|o!l0(~j;gr1EmE}GWqBnUVe#UMNB@H$LD-zGh}$C+MNdyY*J zJqUu6hvazD_cS9x)2%6lTKUTip^ z)_|&4amu6K@WfNd1_N?9viKYP<=o3 z^c&}%8}ml}BjTx0oIe}-2J0in^=@|-#RZ}zs6k^sy4yqtxQCteU-V$^p(2}r?WdZX zu?4w{uGD`s zdV53@H(P;LfBY?BCEf;=aTtO0-e$|c@ajMlcYO1O+Vs_Ct5F4gk@nT6K6Hkbez{=@ zvt^6m28}UBrTWxh3lu?LseRJ)`@HkEYkz_XJ}SVLy*0&b!C+A|6~L*k)K2X`H1fZj zjC*xU<1QYHdE3!spfe9Vm8UbLhbqE3o>q2J(7SeNXsQ z|L{NK=XM`f!qhE+)B(Y`fS}z3UCC%2s;}t&z2o9r!QWONj-im>aM;(Ec#r)3?Zz$d zv|)`?ExD2^;q*lAB?F-IafgZLny`-Crp}0vPJI`Bi(|gEyZIhx`$WgQj~qs`w_$w; z&<&2HGil#;fYnraJ9hp83xOZIwH*p^iK_2_Rp^Vy`R z18D&RmCfKYF+*fKWh#fj)5C#_m|V8h_Z@4`d-jlIgo$#w>O5||2ld^FE{9UmuSi2* z*lCpy)%Tg#>~j3tll$%$d!d3D~po7s_x>_yjgGlS?_DRzc~AodKPCR zPth859;$D*o-(M>(}zBHd?iPRObmUudHLEuu3I;ENt|rKmrurg+V!PXzC5}PK-Zyh z8Gn{6ueT<`+uklnhjIL;+Y0berq=A;85iZnR zPfb6;k!VipvfhwF230+ctg2WuTKgfsL0|HgRnlJH)D(9;y+qLmtw3Lm(r9n1jC@ff zTP)Ec`~R9E-pW$Mah$q_xj*BXwKom+dMGx`$$KO;_b8dG8JaX2WvePs8hulc_?b4li@ zujcc9u`%|G(21$k;$1AZ{#}5`!Dm0*nDhfOOAzq3)j+BN0whukm`?w$Kq2q)?GnFw z=`+xADhBPkopJD8>|iSBpCp)6A^z-@c?0f%g6#%9SrK$;pViS(Jc8=&#|QlZ5j1E& zK8G#%Jhn!Jyp%pD>CDRXa??Z6S1=L*bk>M=><2sg_Y^9GlnkqRwgya4f2Y!hu7JSo zLc0&(z@qp8#PlDi;Q_4ZNg8qh>40bnaq3@Mcr9&h^}3zbbMmQ9MTHk`&cs)z+ zm+|pe=Hw{-ECqcFEmCGxiw<4HJet!@*sL@%erJ{TKEk@Twa3G{Zjl2jP4~y2kD#+` z8x=$3Ma|qM^S?B5kNUqt13$%A_VCqj(d7>?laJ`$2RV39A_=B;hau1<^x!ZAs(uF| z^}&mK&K+Ji{{g$OrkouaAJVSFSUgFG+XBiR@jOK*4#O;ZP|6XEBIrW2Ur~by>k83< z!Qs=1x*Ub1WwkQSas?Qta3{2Or{$}Rze2%b5Gz{c7&?~j;cwzw?wF*4ku-9r{^f{| z)_mHt^SbTL`Kzg>tW(Buy2U~33yS#?KzXe@)Z#dl$?i<4m7z?^kzoIk33=+@HF-QF zRHZBBpjRR?u$IMC^5OJZWXc6d0&Xg|H>4#ce9E&L=a@9B@ud?&joD>EaluHGUXKm* z=%iGN3>p}W@ujkR^zv6@Afr7exm?J>s(QCE(Vo)9mBbKSm zPDKA4!SUW1fpaptjD_8@j1fv@uNXP*6o%B1Cfk}gC`oH7OW&Qc%6LffXNO~1ZWa|! zxx*ZG9b?X8!eXXnD5089Sx?9LKk$!~1&qoO+av8eH1jh(YPL6!%Bzh9ViW5K5Au;% zlJvS*Hl<~ksrVUGULcNIN_+HD=f4)x^*QyAmYj%8T{*0Gx+xYQm^Z}A?Yu|z&!T6k zFBTj?9`6`MH!q+aFONXx)IYA$c=GIN$Kv+EZD2joT_{XOXwn^ez;yb@SiYIEqXivVMu6zov)EsgudoH=3rucI?6gq!N<@CSVi=(um zfDsDF#e~qy_%wnem7JEg!tjN|M)G5BmCB1Wu8f6kI`+fR#=z=Y`yY=ox^_adF(5FnU#8@K0@Jmrdm$Kg2DSbrlPaiW{oCV}ulU#1c zseePK=Au?!6MP3(68g}XKfsfUrNi$3R6Ftw@XFWeK85l9n^2ygXHkOBf1Gm^fy}|F ze+a08{pyCwDwmAvsCw5yNA1EWO zsp9GXE|5C)Zz~;nIq2vgvwUk}tzfPE#{4{;z69TToy^PN=N>r$O6ecnD_^Fj-^}XW zJ}eC@Gp|?yp7-HxDE_h)k`G-M+Qt2>r_9S%)qL7zKdQZ*{^_SmE5G<^(!Mg+!4|?N zr}X;Ap}GgVcYpf%iIsw3_8rDQ@+MB$_`CuWYv3<>`^RUb#(4a!a(}~V3?{7=Na`=W zcf~6CFZ{UjdLl8Fp}es6YI!uM;6sRqbf^hbMy79h6Q7(qs?wkUr6n}rn}4$>%$;nh zkdsFSd5yRW`4t9B`R>U*aep%ff{0q#J|#UnlsrKwC->UIoc}g2%##wD=jf0FOsVsj z_h=*%L#O`Xv^~p~Ej!|czqdk~8yv)^Nh;&WZF{-$TJw3TdNn)a$Rns|dX*dVY{68o z%7duxa%V32dJw1=CqOJb-sFcqo@=O7!_YtN_UnuXC%&Dz_qI&jdGjKrSwa7>S?w=c zEOvFddIClhhRrxA^3C3JXXSZLB52SL_z1pC3{Cq18EH*g{sV_T^*PK)$^@#MLSEmH z>j>j-{G&QBQ*x6>WLgT!F*?+#7EHkTBP=o3$p_eHcUfWaVEH3u-n$RQRXy|T4Hk<_ zd|;Vp)*C77$DE6L=r@R69=w#M+rL@fI_oMhu~Or!eZyk9brVdV9zds=e$aYET~?j{w*WD1yfPoF#Mcsz~G?Ya~ncPWtBQL|9&3sdGl)5)`) z-blx9{|}A=B!isag{Z{Osec#mslyRpwmmod38trFs~EB;~& z2YX8U^5k1ek^V+qWy)Bi7rF=<}FY+mFUGjS%BqX-#GH^QeuTEBs_;Pyaow7$9W-P8m!4G>0^xCNrgD-%BzC8bndZ@rg%AWlWIDAvUhm}zn$Ajm%Zdk zLXN{UojIvc{0+r;Bag}_G1B7r*9(Og=6^GuQY$rH?$yg@be-Nh_3xC9nEh^rVGUQd z=HpxbREA%({!PNBLWN{W9iHl`=N??D4qOtSZsNF%kDurjACC>?H4~M*RHw>?Ah<4+ zge{&^|2k^D@>MS^jK70Zpb+(4z0@$L{#n%?L*6bC|IV<>OwdHU=Trenjjs+*JJBuf zy?M?v)VzAB8kNQrn84(hnX6}1o{>ATXivc$j4V3HbApp!HlRGKdaY-Qr?byj)p@!8Ioj0CzeQf^TzwtOU1o}ES3Z@Z zp1`2FH|LA#WvPJH9LahRRB?|I7PEx?mIf`;CSi z$XW0}%cyl(k5+lML)&RFYV(LC&xaGaUQ*?bL3!0nTHU{W`Q+3;65XKJ`4L~N3_r|K zLw;70OIQBA-R6!b`{|KCrmRu|dHMj485Mv~Q1BsyjD7l0$NBghBYt7TIfT-D6`7Q4 z5)$wzMV>rHDY>jIz_RfbbdKXly(Li*Oez3A`MW`>r;Z8}=_4k} zyB*852~mrdbNi5eSgjhrql4AJ{ayO9I>1q?Topi?B>t$!uLhPy8oEDT_~rNNP4$zr zyRv)*o;s}jo417dISj>49_`D6s; z6H4L_NOV>zJ&dE|6slCqqsw_RoPVLa&c{bU9wYvsG;jW>)HM4Gsr;4ZUmSh?&7e|` zoF#FUa%tFb%Z3}1?t9gZ7AqwrBa_pD9He}GljnVniqN@U)D#e%YT`22@)-BsIR95) z&)nNr?p1J~o}pglL{PiH|9O(6wQq)*nQyS=1q<*!h3pYJ%GAoK%aLuc*R z-kK2}$b?9rKv<6!G!zan$Ek!KJHOE7bl~=NUJ}Q|;F5`#i>R+B`M^z`n?=z0AdeXT zbxf0ccj@TzVHZXq_|5rhINP=7ulf1<$mfWyU`~X>O}T`_hL>N*FQ_6ej8jET?T6Kan!|* z#=vj*&p`0!0UT6@qJ{syZ{2&_&$hlCyR?MalMaRX1XS0rbRJg*-GW>QgwDr%aNyo- z?o<5bVSay9xJD6d2ju&M+Py!3rj`lh&!HM?n@DNu6XGb^2jjdeB#g#1b|;XakCzOzo=OLVcv)D1niRS7r@od33M zX1N~49+hr<=?tx7vhzo9_c^K^f6jQj{HYShz5&j?rQEf>=1lzWsPl9s+@~S_64kG{ zPc7Ub-n6;T>G;yIL&lGdtsk2>WbEY85n`1T=*hkZcJ4;HFH9~~;x-cJ2MJ(yATgF@xZ{|6M> BYPSFY delta 40476 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 Pazs72x6P4a{^tJzp3I6O diff --git a/packages/api/src/env.ts b/packages/api/src/env.ts index 141b82b9..be023dd4 100644 --- a/packages/api/src/env.ts +++ b/packages/api/src/env.ts @@ -1,13 +1,10 @@ import { parseEnv } from "shared/env"; export const env = parseEnv((z) => ({ - // process PORT: z.coerce.number().default(52001), - HOST: z.string().default("0.0.0.0"), - - // config.env - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), + HOST: z.string().optional(), + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), S3_ENDPOINT: z.string(), S3_REGION: z.string(), S3_ACCESS_KEY: z.string(), diff --git a/packages/app/src/routes/(dashboard)/_layout/player.tsx b/packages/app/src/routes/(dashboard)/_layout/player.tsx index 28a18616..7a173b25 100644 --- a/packages/app/src/routes/(dashboard)/_layout/player.tsx +++ b/packages/app/src/routes/(dashboard)/_layout/player.tsx @@ -1,4 +1,4 @@ -import { Card } from "@nextui-org/react"; +import { Card, Modal, ModalBody, ModalContent } from "@nextui-org/react"; import { createFileRoute } from "@tanstack/react-router"; import { useRef, useState } from "react"; import { CodeEditor } from "../../../components/CodeEditor"; @@ -14,6 +14,7 @@ export const Route = createFileRoute("/(dashboard)/_layout/player")({ function RouteComponent() { const formRef = useRef(null); const [url, setUrl] = useState(null); + const [error, setError] = useState(null); const schema = useSwaggerSchema( `${window.__ENV__.PUBLIC_STITCHER_ENDPOINT}/swagger/json`, @@ -21,7 +22,7 @@ function RouteComponent() { ); return ( -
+
@@ -46,6 +47,8 @@ function RouteComponent() { schema={schema} localStorageKey="stitcherEditor" onSave={async (body) => { + setError(null); + const response = await fetch( `${window.__ENV__.PUBLIC_STITCHER_ENDPOINT}/session`, { @@ -57,14 +60,30 @@ function RouteComponent() { }, ); + const data = await response.json(); if (response.ok) { - const { url } = await response.json(); - formRef.current?.setValue("url", url); - setUrl(url); + formRef.current?.setValue("url", data.url); + setUrl(data.url); + } else { + setError(data); } }} /> + setError(null)} + size="5xl" + scrollBehavior="inside" + > + + +
+              {JSON.stringify(error, null, 2)}
+            
+
+
+
); } diff --git a/packages/artisan/src/env.ts b/packages/artisan/src/env.ts index da667798..6ada0bbd 100644 --- a/packages/artisan/src/env.ts +++ b/packages/artisan/src/env.ts @@ -1,12 +1,11 @@ import { parseEnv } from "shared/env"; export const env = parseEnv((z) => ({ - // config.env + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), S3_ENDPOINT: z.string(), S3_REGION: z.string(), S3_ACCESS_KEY: z.string(), S3_SECRET_KEY: z.string(), S3_BUCKET: z.string(), - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), })); diff --git a/packages/bolt/src/env.ts b/packages/bolt/src/env.ts index de1a591a..809bfaf1 100644 --- a/packages/bolt/src/env.ts +++ b/packages/bolt/src/env.ts @@ -2,8 +2,8 @@ import { parseEnv } from "shared/env"; const env = parseEnv((z) => ({ // config.env - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), })); export const connection = { diff --git a/packages/stitcher/package.json b/packages/stitcher/package.json index 1218c0ec..57b2d733 100644 --- a/packages/stitcher/package.json +++ b/packages/stitcher/package.json @@ -28,13 +28,13 @@ "@matvp91/elysia-swagger": "^2.0.0", "@superstreamer/api": "workspace:*", "@xmldom/xmldom": "^0.8.10", - "cryptr": "^6.3.0", "dom-parser": "^1.1.5", "elysia": "^1.1.24", "hh-mm-ss": "^1.2.0", "lru-cache": "^11.0.2", "luxon": "^3.5.0", "redis": "^4.7.0", + "secure-encrypt": "^1.0.12", "shared": "workspace:*", "superjson": "^2.2.1", "uuid": "^10.0.0", diff --git a/packages/stitcher/runtime/local.ts b/packages/stitcher/runtime/local.ts index 8b5dc109..27dcffd8 100644 --- a/packages/stitcher/runtime/local.ts +++ b/packages/stitcher/runtime/local.ts @@ -1,10 +1,5 @@ -import { parseEnv } from "shared/env"; import { createApp } from "../src"; - -const env = parseEnv((z) => ({ - PORT: z.coerce.number().default(52002), - HOST: z.string().default("0.0.0.0"), -})); +import { env } from "../src/env"; const app = createApp({ aot: true, diff --git a/packages/stitcher/src/adapters/kv/cloudflare-kv.ts b/packages/stitcher/src/adapters/kv/cloudflare-kv.ts new file mode 100644 index 00000000..d4b22d43 --- /dev/null +++ b/packages/stitcher/src/adapters/kv/cloudflare-kv.ts @@ -0,0 +1,21 @@ +import type { KVNamespace } from "@cloudflare/workers-types"; + +// Make sure wrangler.toml has a binding named "kv". +const kv = process.env["kv"] as unknown as KVNamespace; + +if (!kv) { + throw new ReferenceError( + 'No kv found for Cloudflare, make sure you have "kv"' + + " set as binding in wrangler.toml.", + ); +} + +export async function set(key: string, value: string, ttl: number) { + await kv.put(key, value, { + expirationTtl: ttl, + }); +} + +export async function get(key: string) { + return await kv.get(key); +} diff --git a/packages/stitcher/src/adapters/kv/index.ts b/packages/stitcher/src/adapters/kv/index.ts new file mode 100644 index 00000000..7109b935 --- /dev/null +++ b/packages/stitcher/src/adapters/kv/index.ts @@ -0,0 +1,15 @@ +import { env } from "../../env"; + +interface KeyValue { + set(key: string, value: string, ttl: number): Promise; + get(key: string): Promise; +} + +export let kv: KeyValue; + +// Map each KV adapter here to their corresponding import. +if (env.KV === "cloudflare-kv") { + kv = await import("./cloudflare-kv"); +} else if (env.KV === "redis") { + kv = await import("./redis"); +} diff --git a/packages/stitcher/src/adapters/kv/redis.ts b/packages/stitcher/src/adapters/kv/redis.ts new file mode 100644 index 00000000..0aa4a80f --- /dev/null +++ b/packages/stitcher/src/adapters/kv/redis.ts @@ -0,0 +1,23 @@ +import { createClient } from "redis"; +import { env } from "../../env"; + +const REDIS_PREFIX = "stitcher"; + +const client = createClient({ + socket: { + host: env.REDIS_HOST, + port: env.REDIS_PORT, + }, +}); + +await client.connect(); + +export async function set(key: string, value: string, ttl: number) { + await client.set(`${REDIS_PREFIX}:${key}`, value, { + EX: ttl, + }); +} + +export async function get(key: string) { + return await client.get(`${REDIS_PREFIX}:${key}`); +} diff --git a/packages/stitcher/src/env.ts b/packages/stitcher/src/env.ts index c92213ca..c38a4dc2 100644 --- a/packages/stitcher/src/env.ts +++ b/packages/stitcher/src/env.ts @@ -1,10 +1,13 @@ import { parseEnv } from "shared/env"; export const env = parseEnv((z) => ({ - SERVERLESS: z.coerce.boolean().default(false), + PORT: z.coerce.number().default(52002), + HOST: z.string().optional(), + + KV: z.enum(["redis", "cloudflare-kv"]).default("redis"), + REDIS_HOST: z.string().default("localhost"), + REDIS_PORT: z.coerce.number().default(6379), - REDIS_HOST: z.string(), - REDIS_PORT: z.coerce.number(), PUBLIC_S3_ENDPOINT: z.string(), PUBLIC_STITCHER_ENDPOINT: z.string(), PUBLIC_API_ENDPOINT: z.string(), diff --git a/packages/stitcher/src/filters.ts b/packages/stitcher/src/filters.ts index 02c830bc..0df125cd 100644 --- a/packages/stitcher/src/filters.ts +++ b/packages/stitcher/src/filters.ts @@ -1,3 +1,4 @@ +import { t } from "elysia"; import type { MasterPlaylist } from "./parser"; export interface Filter { @@ -5,6 +6,20 @@ export interface Filter { audioLanguage?: string; } +export function formatFilterToQueryParam(filter?: Filter) { + if (!filter) { + return undefined; + } + return btoa(JSON.stringify(filter)); +} + +export const filterSchema = t.Optional( + t + .Transform(t.String()) + .Decode((value) => JSON.parse(atob(value)) as Filter) + .Encode((filter) => btoa(JSON.stringify(filter))), +); + function parseRange(input: string): [number, number] | null { const match = input.match(/^(\d+)-(\d+)$/); @@ -77,24 +92,3 @@ export function filterMasterPlaylist(master: MasterPlaylist, filter: Filter) { }); } } - -export function getFilterFromQuery(query: Record) { - const filter: Filter = {}; - if ("filter.resolution" in query) { - filter.resolution = query["filter.resolution"]; - } - if ("filter.audioLanguage" in query) { - filter.audioLanguage = query["filter.audioLanguage"]; - } - return filter; -} - -export function getQueryParamsFromFilter(filter: Filter) { - const queryParams: Record = {}; - - Object.entries(filter).forEach(([key, value]) => { - queryParams[`filter.${key}`] = value; - }); - - return queryParams; -} diff --git a/packages/stitcher/src/interstitials.ts b/packages/stitcher/src/interstitials.ts index 98cdb3c3..ccf6f375 100644 --- a/packages/stitcher/src/interstitials.ts +++ b/packages/stitcher/src/interstitials.ts @@ -1,17 +1,18 @@ -import { assert } from "shared/assert"; import { Group } from "./lib/group"; import { makeUrl, resolveUri } from "./lib/url"; import { fetchDuration } from "./playlist"; import { getAdMediasFromAdBreak } from "./vast"; -import { parseVmap } from "./vmap"; +import { toAdBreakTimeOffset } from "./vmap"; import type { DateRange } from "./parser"; import type { Session } from "./session"; +import type { AdMedia } from "./vast"; import type { VmapResponse } from "./vmap"; import type { DateTime } from "luxon"; export type InterstitialType = "ad" | "bumper"; export interface Interstitial { + position: number; url: string; duration?: number; type?: InterstitialType; @@ -24,36 +25,44 @@ interface InterstitialAsset { } export function getStaticDateRanges(startTime: DateTime, session: Session) { - const group = new Group(); + const group = new Group(); if (session.vmapResponse) { - const vmap = parseVmap(session.vmapResponse); - for (const adBreak of vmap.adBreaks) { - group.add(adBreak.timeOffset, "ad"); + for (const adBreak of session.vmapResponse.adBreaks) { + const timeOffset = toAdBreakTimeOffset(adBreak); + if (timeOffset !== null) { + group.add(timeOffset, "ad"); + } } } - session.interstitials?.forEach((timeOffset, interstitials) => { - interstitials.forEach((interstitial) => { - group.add(timeOffset, interstitial.type); - }); - }); + if (session.interstitials) { + for (const interstitial of session.interstitials) { + group.add(interstitial.position, interstitial.type); + } + } const dateRanges: DateRange[] = []; group.forEach((timeOffset, types) => { const startDate = startTime.plus({ seconds: timeOffset }); - const assetListUrl = makeUrl(`session/${session.id}/asset-list.json`, { - startDate: startDate.toISO(), + const assetListUrl = makeAssetListUrl({ + timeOffset, + session, }); const clientAttributes: Record = { RESTRICT: "SKIP,JUMP", "RESUME-OFFSET": 0, "ASSET-LIST": assetListUrl, + CUE: "ONCE", }; + if (timeOffset === 0) { + clientAttributes["CUE"] += ",PRE"; + } + if (types.length) { clientAttributes["SPRS-TYPES"] = types.join(","); } @@ -69,51 +78,34 @@ export function getStaticDateRanges(startTime: DateTime, session: Session) { return dateRanges; } -export async function getAssets(session: Session, lookupDate: DateTime) { +export async function getAssets(session: Session, timeOffset?: number) { const assets: InterstitialAsset[] = []; - if (session.startTime) { + if (timeOffset !== undefined) { if (session.vmapResponse) { - const vmap = parseVmap(session.vmapResponse); - const vmapAssets = await getAssetsFromVmap( - vmap, - session.startTime, - lookupDate, - ); - assets.push(...vmapAssets); + const items = await getAssetsFromVmap(session.vmapResponse, timeOffset); + assets.push(...items); } if (session.interstitials) { - const groupAssets = await getAssetsFromGroup( - session.interstitials, - session.startTime, - lookupDate, - ); - assets.push(...groupAssets); + const items = await getAssetsFromGroup(session.interstitials, timeOffset); + assets.push(...items); } } return assets; } -async function getAssetsFromVmap( - vmap: VmapResponse, - baseDate: DateTime, - lookupDate: DateTime, -) { - const timeOffset = getTimeOffset(baseDate, lookupDate); - const adBreak = vmap.adBreaks.find( - (adBreak) => adBreak.timeOffset === timeOffset, +async function getAssetsFromVmap(vmap: VmapResponse, timeOffset: number) { + const adBreaks = vmap.adBreaks.filter( + (adBreak) => toAdBreakTimeOffset(adBreak) === timeOffset, ); - - if (!adBreak) { - // No adbreak found for the time offset. There's nothing left to do. - return []; - } - const assets: InterstitialAsset[] = []; - const adMedias = await getAdMediasFromAdBreak(adBreak); + const adMedias: AdMedia[] = []; + for (const adBreak of adBreaks) { + adMedias.push(...(await getAdMediasFromAdBreak(adBreak))); + } for (const adMedia of adMedias) { assets.push({ @@ -127,17 +119,16 @@ async function getAssetsFromVmap( } async function getAssetsFromGroup( - interstitialsGroup: Group, - baseDate: DateTime, - lookupDate: DateTime, + interstitials: Interstitial[], + timeOffset: number, ) { const assets: InterstitialAsset[] = []; - const timeOffset = getTimeOffset(baseDate, lookupDate); - - const interstitials = interstitialsGroup.get(timeOffset); - for (const interstitial of interstitials) { + if (interstitial.position !== timeOffset) { + continue; + } + let duration = interstitial.duration; if (!duration) { duration = await fetchDuration(interstitial.url); @@ -153,8 +144,9 @@ async function getAssetsFromGroup( return assets; } -function getTimeOffset(baseDate: DateTime, lookupDate: DateTime) { - const { seconds } = lookupDate.diff(baseDate, "seconds").toObject(); - assert(seconds); - return seconds; +function makeAssetListUrl(params: { timeOffset: number; session?: Session }) { + return makeUrl("out/asset-list.json", { + timeOffset: params.timeOffset, + sid: params.session?.id, + }); } diff --git a/packages/stitcher/src/kv/cloudflare.ts b/packages/stitcher/src/kv/cloudflare.ts deleted file mode 100644 index af4af2ef..00000000 --- a/packages/stitcher/src/kv/cloudflare.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { KVNamespace } from "@cloudflare/workers-types"; - -const env = process.env as unknown as { kv: KVNamespace }; - -export default { - async set(key: string, value: string, ttl: number) { - await env.kv.put(key, value, { - expirationTtl: ttl, - }); - }, - async get(key: string) { - return await env.kv.get(key); - }, -}; diff --git a/packages/stitcher/src/kv/index.ts b/packages/stitcher/src/kv/index.ts deleted file mode 100644 index 2e999e94..00000000 --- a/packages/stitcher/src/kv/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { env } from "../env"; - -async function createKv() { - if (env.SERVERLESS) { - return await import("./cloudflare"); - } - return await import("./redis"); -} - -export const kv = (await createKv()).default; diff --git a/packages/stitcher/src/kv/redis.ts b/packages/stitcher/src/kv/redis.ts deleted file mode 100644 index 88256706..00000000 --- a/packages/stitcher/src/kv/redis.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { createClient } from "redis"; -import { env } from "../env"; - -const client = createClient({ - socket: { - host: env.REDIS_HOST, - port: env.REDIS_PORT, - }, -}); - -await client.connect(); - -export default { - async set(key: string, value: string, ttl: number) { - await client.set(`stitcher:${key}`, value, { - EX: ttl, - }); - }, - async get(key: string) { - return await client.get(`stitcher:${key}`); - }, -}; diff --git a/packages/stitcher/src/lib/crypto.ts b/packages/stitcher/src/lib/crypto.ts index b238e937..669fb2c8 100644 --- a/packages/stitcher/src/lib/crypto.ts +++ b/packages/stitcher/src/lib/crypto.ts @@ -1,14 +1,12 @@ -import Cryptr from "cryptr"; +import * as crypto from "secure-encrypt"; import { env } from "../env"; -const cryptr = new Cryptr(env.SUPER_SECRET ?? "__UNSECURE__", { - encoding: "base64", -}); +const secret = env.SUPER_SECRET ?? "__UNSECURE__"; export function encrypt(value: string) { - return cryptr.encrypt(value); + return btoa(crypto.encrypt(value, secret)); } export function decrypt(value: string) { - return cryptr.decrypt(value); + return crypto.decrypt(atob(value), secret); } diff --git a/packages/stitcher/src/lib/group.ts b/packages/stitcher/src/lib/group.ts index 18e69a95..b44d5324 100644 --- a/packages/stitcher/src/lib/group.ts +++ b/packages/stitcher/src/lib/group.ts @@ -1,13 +1,15 @@ export class Group { constructor(public map = new Map>()) {} - add(key: K, value: V) { + add(key: K, value?: V) { let set = this.map.get(key); if (!set) { set = new Set(); this.map.set(key, set); } - set.add(value); + if (value !== undefined) { + set.add(value); + } } forEach(callback: (value: K, items: V[]) => void) { diff --git a/packages/stitcher/src/lib/json.ts b/packages/stitcher/src/lib/json.ts index dd00413d..cc01eafe 100644 --- a/packages/stitcher/src/lib/json.ts +++ b/packages/stitcher/src/lib/json.ts @@ -1,7 +1,6 @@ import { DateTime } from "luxon"; import { assert } from "shared/assert"; import { parse, registerCustom, stringify } from "superjson"; -import { Group } from "./group"; registerCustom( { @@ -16,15 +15,6 @@ registerCustom( "DateTime", ); -registerCustom( - { - isApplicable: (value) => value instanceof Group, - serialize: (group) => stringify(group.map), - deserialize: (value) => new Group(parse(value)), - }, - "Group", -); - export const JSON = { parse, stringify, diff --git a/packages/stitcher/src/lib/url.ts b/packages/stitcher/src/lib/url.ts index 3ce71b58..9fe7360a 100644 --- a/packages/stitcher/src/lib/url.ts +++ b/packages/stitcher/src/lib/url.ts @@ -61,7 +61,7 @@ export function joinUrl(urlFile: string, filePath: string) { export function makeUrl( path: string, - params: Record = {}, + params: Record = {}, ) { return buildUrl(`${env.PUBLIC_STITCHER_ENDPOINT}/${path}`, params); } diff --git a/packages/stitcher/src/parser/helpers.ts b/packages/stitcher/src/parser/helpers.ts index 4e8b59d1..5e2e03c3 100644 --- a/packages/stitcher/src/parser/helpers.ts +++ b/packages/stitcher/src/parser/helpers.ts @@ -1,6 +1,6 @@ import type { Rendition, Variant } from "./types"; -export function groupRenditions(variants: Variant[]) { +export function getRenditions(variants: Variant[]) { const group = new Set(); variants.forEach((variant) => { variant.audio.forEach((rendition) => { diff --git a/packages/stitcher/src/parser/index.ts b/packages/stitcher/src/parser/index.ts index 44ce678b..c0559a96 100644 --- a/packages/stitcher/src/parser/index.ts +++ b/packages/stitcher/src/parser/index.ts @@ -1,5 +1,5 @@ export { parseMasterPlaylist, parseMediaPlaylist } from "./parse"; export { stringifyMasterPlaylist, stringifyMediaPlaylist } from "./stringify"; -export { groupRenditions } from "./helpers"; +export { getRenditions } from "./helpers"; export * from "./types"; diff --git a/packages/stitcher/src/parser/parse.ts b/packages/stitcher/src/parser/parse.ts index fa9791c3..544c3fbe 100644 --- a/packages/stitcher/src/parser/parse.ts +++ b/packages/stitcher/src/parser/parse.ts @@ -50,29 +50,27 @@ function formatMediaPlaylist(tags: Tag[]): MediaPlaylist { } }); - const segments = tags.reduce((acc, [name], index) => { - if (name !== "EXTINF") { - return acc; - } + const segments: Segment[] = []; + let segmentStart = -1; - let segmentStart = index; - const segmentEnd = index + 1; - for (let i = index; i > 0; i--) { - if (tags[i]?.[0] === "LITERAL") { - segmentStart = i + 1; - break; - } + tags.forEach(([name], index) => { + if (isSegmentTag(name)) { + segmentStart = index - 1; } - const segmentTags = tags.slice(segmentStart, segmentEnd); - const uri = nextLiteral(tags, index); - - const segment = parseSegment(segmentTags, uri, map); + if (name === "LITERAL") { + if (segmentStart < 0) { + throw new Error("LITERAL: no segment start"); + } + const segmentTags = tags.slice(segmentStart, index + 1); + const uri = nextLiteral(segmentTags, segmentTags.length - 2); - acc.push(segment); + const segment = parseSegment(segmentTags, uri, map); + segments.push(segment); - return acc; - }, []); + segmentStart = -1; + } + }); assert(targetDuration); @@ -223,6 +221,17 @@ function nextLiteral(tags: Tag[], index: number) { return value; } +function isSegmentTag(name: Tag[0]) { + switch (name) { + case "EXTINF": + case "EXT-X-DISCONTINUITY": + case "EXT-X-MAP": + case "EXT-X-PROGRAM-DATE-TIME": + return true; + } + return false; +} + export function parseMasterPlaylist(text: string) { const tags = lexicalParse(text); return formatMasterPlaylist(tags); diff --git a/packages/stitcher/src/playlist.ts b/packages/stitcher/src/playlist.ts index a7ed4d04..c8e3cd44 100644 --- a/packages/stitcher/src/playlist.ts +++ b/packages/stitcher/src/playlist.ts @@ -1,45 +1,44 @@ -import { DateTime } from "luxon"; import { assert } from "shared/assert"; -import { filterMasterPlaylist, getQueryParamsFromFilter } from "./filters"; +import { filterMasterPlaylist, formatFilterToQueryParam } from "./filters"; import { getAssets, getStaticDateRanges } from "./interstitials"; import { encrypt } from "./lib/crypto"; import { joinUrl, makeUrl, resolveUri } from "./lib/url"; import { - groupRenditions, parseMasterPlaylist, parseMediaPlaylist, stringifyMasterPlaylist, stringifyMediaPlaylist, } from "./parser"; +import { getRenditions } from "./parser/helpers"; import type { Filter } from "./filters"; import type { Session } from "./session"; -export async function formatMasterPlaylist( - masterUrl: string, - options: { - filter: Filter; - session?: Session; - }, -) { - const master = await fetchMasterPlaylist(masterUrl); +export async function formatMasterPlaylist(params: { + origUrl: string; + sessionId?: string; + filter?: Filter; +}) { + const master = await fetchMasterPlaylist(params.origUrl); - filterMasterPlaylist(master, options.filter); + if (params.filter) { + filterMasterPlaylist(master, params.filter); + } for (const variant of master.variants) { - const url = joinUrl(masterUrl, variant.uri); + const url = joinUrl(params.origUrl, variant.uri); variant.uri = makeMediaUrl({ url, - session: options.session, - type: "VIDEO", + sessionId: params.sessionId, }); } - const renditions = groupRenditions(master.variants); + const renditions = getRenditions(master.variants); + renditions.forEach((rendition) => { - const url = joinUrl(masterUrl, rendition.uri); + const url = joinUrl(params.origUrl, rendition.uri); rendition.uri = makeMediaUrl({ url, - session: options.session, + sessionId: params.sessionId, type: rendition.type, }); }); @@ -48,23 +47,33 @@ export async function formatMasterPlaylist( } export async function formatMediaPlaylist( - session: Session, - mediaType: string, mediaUrl: string, + session?: Session, + renditionType?: string, ) { - const { startTime } = session; - assert(startTime, "No startTime in session"); - const media = await fetchMediaPlaylist(mediaUrl); - // Type is the actual value of EXT-X-MEDIA, thus it's in capital. Let's lowercase it first. - const type = mediaType.toLowerCase(); + // We're in a video playlist when we have no renditionType passed along, + // this means it does not belong to EXT-X-MEDIA, or when we explicitly VIDEO. + const videoPlaylist = !renditionType || renditionType === "VIDEO"; + const firstSegment = media.segments[0]; + + if (session) { + // If we have a session, we must have a startTime thus meaning we started. + assert(session.startTime); + + if (media.endlist) { + assert(firstSegment); + firstSegment.programDateTime = session.startTime; + } - if (type === "video" && media.endlist && media.segments[0]) { - // When we have an endlist, the playlist is static. We can check whether we need - // to add dateRanges. - media.segments[0].programDateTime = startTime; - media.dateRanges = getStaticDateRanges(startTime, session); + if (videoPlaylist && firstSegment?.programDateTime) { + // If we have an endlist and a PDT, we can add static date ranges based on this. + media.dateRanges = getStaticDateRanges( + firstSegment.programDateTime, + session, + ); + } } media.segments.forEach((segment) => { @@ -77,9 +86,8 @@ export async function formatMediaPlaylist( return stringifyMediaPlaylist(media); } -export async function formatAssetList(session: Session, startDate: string) { - const lookupDate = DateTime.fromISO(startDate); - const assets = await getAssets(session, lookupDate); +export async function formatAssetList(session: Session, timeOffset?: number) { + const assets = await getAssets(session, timeOffset); return { ASSETS: assets, }; @@ -114,24 +122,34 @@ export async function fetchDuration(uri: string) { export function makeMasterUrl(params: { url: string; - filter: Filter; + filter?: Filter; session?: Session; }) { - return makeUrl("out/master.m3u8", { + const fil = formatFilterToQueryParam(params.filter); + + const outUrl = makeUrl("out/master.m3u8", { eurl: encrypt(params.url), sid: params.session?.id, - ...getQueryParamsFromFilter(params.filter), + fil, }); + + const url = params.session + ? makeUrl(`session/${params.session.id}/master.m3u8`, { + fil, + }) + : undefined; + + return { url, outUrl }; } function makeMediaUrl(params: { - type: string; url: string; - session?: Session; + sessionId?: string; + type?: string; }) { return makeUrl("out/playlist.m3u8", { - type: params.type, eurl: encrypt(params.url), - sid: params.session?.id, + sid: params.sessionId, + type: params.type, }); } diff --git a/packages/stitcher/src/routes/session.ts b/packages/stitcher/src/routes/session.ts index ad97ed0d..cbab36fb 100644 --- a/packages/stitcher/src/routes/session.ts +++ b/packages/stitcher/src/routes/session.ts @@ -1,7 +1,6 @@ import { Elysia, t } from "elysia"; -import { getFilterFromQuery, getQueryParamsFromFilter } from "../filters"; +import { filterSchema } from "../filters"; import { decrypt } from "../lib/crypto"; -import { makeUrl } from "../lib/url"; import { formatAssetList, formatMasterPlaylist, @@ -13,6 +12,27 @@ import { getSession, processSessionOnMasterReq, } from "../session"; +import type { Filter } from "../filters"; +import type { Session } from "../session"; + +async function handleMasterPlaylist( + origUrl: string, + session?: Session, + filter?: Filter, +) { + if (session) { + await processSessionOnMasterReq(session); + } + + const sessionId = session?.id; + const playlist = await formatMasterPlaylist({ + origUrl, + sessionId, + filter, + }); + + return playlist; +} export const sessionRoutes = new Elysia() .post( @@ -20,10 +40,12 @@ export const sessionRoutes = new Elysia() async ({ body }) => { const session = await createSession(body); - const filter = body.filter ?? {}; + const filter = body.filter; - const url = makeUrl(`session/${session.id}/master.m3u8`, { - ...getQueryParamsFromFilter(filter), + const { url } = makeMasterUrl({ + url: session.url, + filter, + session, }); return { url }; @@ -40,7 +62,7 @@ export const sessionRoutes = new Elysia() interstitials: t.Optional( t.Array( t.Object({ - timeOffset: t.Number(), + position: t.Number(), uri: t.String(), duration: t.Optional(t.Number()), type: t.Optional(t.Union([t.Literal("ad"), t.Literal("bumper")])), @@ -89,40 +111,37 @@ export const sessionRoutes = new Elysia() ) .get( "/session/:sessionId/master.m3u8", - async ({ params, query, redirect }) => { + async ({ set, params, query }) => { const session = await getSession(params.sessionId); - const url = makeMasterUrl({ - url: session.url, - filter: getFilterFromQuery(query), + + const playlist = await handleMasterPlaylist( + session.url, session, - }); - return redirect(url, 302); + query.fil, + ); + + set.headers["content-type"] = "application/vnd.apple.mpegurl"; + + return playlist; }, { params: t.Object({ sessionId: t.String(), }), query: t.Object({ - "filter.resolution": t.Optional(t.String()), - "filter.audioLanguage": t.Optional(t.String()), + fil: filterSchema, }), }, ) .get( "/out/master.m3u8", async ({ set, query }) => { - const session = await getSession(query.sid); - - await processSessionOnMasterReq(session); - - const filter = getFilterFromQuery(query); const url = decrypt(query.eurl); - const playlist = await formatMasterPlaylist(url, { - session, - filter, - }); - set.headers["content-type"] = "application/x-mpegURL"; + const session = query.sid ? await getSession(query.sid) : undefined; + const playlist = await handleMasterPlaylist(url, session, query.fil); + + set.headers["content-type"] = "application/vnd.apple.mpegurl"; return playlist; }, @@ -132,21 +151,22 @@ export const sessionRoutes = new Elysia() }, query: t.Object({ eurl: t.String(), - sid: t.String(), - "filter.resolution": t.Optional(t.String()), - "filter.audioLanguage": t.Optional(t.String()), + sid: t.Optional(t.String()), + fil: filterSchema, }), }, ) .get( "/out/playlist.m3u8", async ({ set, query }) => { - const session = await getSession(query.sid); + const session = query.sid ? await getSession(query.sid) : undefined; const url = decrypt(query.eurl); - const playlist = await formatMediaPlaylist(session, query.type, url); + const type = query.type; + + const playlist = await formatMediaPlaylist(url, session, type); - set.headers["content-type"] = "application/x-mpegURL"; + set.headers["content-type"] = "application/vnd.apple.mpegurl"; return playlist; }, @@ -155,28 +175,29 @@ export const sessionRoutes = new Elysia() hide: true, }, query: t.Object({ - type: t.String(), eurl: t.String(), - sid: t.String(), + sid: t.Optional(t.String()), + type: t.Optional(t.String()), }), }, ) .get( - "/session/:sessionId/asset-list.json", - async ({ params, query }) => { - const session = await getSession(params.sessionId); + "/out/asset-list.json", + async ({ query }) => { + const sessionId = query.sid; + const timeOffset = query.timeOffset; + + const session = await getSession(sessionId); - return await formatAssetList(session, query.startDate); + return await formatAssetList(session, timeOffset); }, { detail: { hide: true, }, - params: t.Object({ - sessionId: t.String(), - }), query: t.Object({ - startDate: t.String(), + timeOffset: t.Optional(t.Number()), + sid: t.String(), _HLS_primary_id: t.Optional(t.String()), }), }, diff --git a/packages/stitcher/src/session.ts b/packages/stitcher/src/session.ts index eafaab74..48c7731d 100644 --- a/packages/stitcher/src/session.ts +++ b/packages/stitcher/src/session.ts @@ -1,10 +1,11 @@ import { randomUUID } from "crypto"; import { DateTime } from "luxon"; -import { kv } from "./kv"; -import { Group } from "./lib/group"; +import { kv } from "./adapters/kv"; import { JSON } from "./lib/json"; import { resolveUri } from "./lib/url"; +import { fetchVmap } from "./vmap"; import type { Interstitial, InterstitialType } from "./interstitials"; +import type { VmapParams, VmapResponse } from "./vmap"; export interface Session { id: string; @@ -14,11 +15,9 @@ export interface Session { startTime?: DateTime; // User defined options - vmap?: { - url: string; - }; - vmapResponse?: string; - interstitials?: Group; + vmap?: VmapParams; + vmapResponse?: VmapResponse; + interstitials?: Interstitial[]; } export async function createSession(params: { @@ -27,7 +26,7 @@ export async function createSession(params: { url: string; }; interstitials?: { - timeOffset: number; + position: number; uri: string; duration?: number; type?: InterstitialType; @@ -45,15 +44,14 @@ export async function createSession(params: { }; if (params.interstitials) { - const group = new Group(); - params.interstitials.forEach((interstitial) => { - group.add(interstitial.timeOffset, { + session.interstitials = params.interstitials.map((interstitial) => { + return { + position: interstitial.position, url: resolveUri(interstitial.uri), duration: interstitial.duration, type: interstitial.type, - }); + }; }); - session.interstitials = group; } // We'll initially store the session for 10 minutes, if it's not been consumed @@ -82,14 +80,7 @@ export async function processSessionOnMasterReq(session: Session) { session.startTime = DateTime.now(); if (session.vmap) { - const USER_AGENT = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"; - const response = await fetch(session.vmap.url, { - headers: { - "User-Agent": USER_AGENT, - }, - }); - session.vmapResponse = await response.text(); + session.vmapResponse = await fetchVmap(session.vmap); delete session.vmap; } diff --git a/packages/stitcher/src/vast.ts b/packages/stitcher/src/vast.ts index ad6c7458..8e7fc79b 100644 --- a/packages/stitcher/src/vast.ts +++ b/packages/stitcher/src/vast.ts @@ -36,27 +36,20 @@ async function getAdMedias(adBreak: VmapAdBreak): Promise { const vastClient = new VASTClient(); const parser = new DOMParser(); - const result: AdMedia[] = []; - - for (const slot of adBreak.slots) { - let vastResponse: VastResponse | undefined; - - if (slot.vastUrl) { - vastResponse = await vastClient.get(slot.vastUrl); - } else if (slot.vastData) { - const xml = parser.parseFromString(slot.vastData, "text/xml"); - vastResponse = await vastClient.parseVAST(xml); - } + let vastResponse: VastResponse | undefined; - if (!vastResponse) { - continue; - } + if (adBreak.vastUrl) { + vastResponse = await vastClient.get(adBreak.vastUrl); + } else if (adBreak.vastData) { + const xml = parser.parseFromString(adBreak.vastData, "text/xml"); + vastResponse = await vastClient.parseVAST(xml); + } - const adMedias = await formatVastResponse(vastResponse); - result.push(...adMedias); + if (!vastResponse) { + return []; } - return result; + return await formatVastResponse(vastResponse); } async function scheduleForPackage(adMedia: AdMedia) { diff --git a/packages/stitcher/src/vmap.ts b/packages/stitcher/src/vmap.ts index 843b5dc2..ebfbe701 100644 --- a/packages/stitcher/src/vmap.ts +++ b/packages/stitcher/src/vmap.ts @@ -1,21 +1,33 @@ import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; -import * as timeFormat from "hh-mm-ss"; +import { toS } from "hh-mm-ss"; -export interface VmapSlot { +export interface VmapAdBreak { + timeOffset: string; vastUrl?: string; vastData?: string; } -export interface VmapAdBreak { - timeOffset: number; - slots: VmapSlot[]; -} - export interface VmapResponse { adBreaks: VmapAdBreak[]; } -export function parseVmap(text: string): VmapResponse { +export interface VmapParams { + url: string; +} + +export async function fetchVmap(params: VmapParams): Promise { + const USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36"; + const response = await fetch(params.url, { + headers: { + "User-Agent": USER_AGENT, + }, + }); + const text = await response.text(); + return parseVmap(text); +} + +function parseVmap(text: string): VmapResponse { const parser = new DOMParser(); const doc = parser.parseFromString(text, "text/xml"); const rootElement = doc.documentElement; @@ -24,51 +36,50 @@ export function parseVmap(text: string): VmapResponse { throw new Error("Url did not resolve in a vmap"); } - const adBreaks: VmapAdBreak[] = []; - - childList(rootElement).forEach((element) => { - if (element.localName === "AdBreak") { - const timeOffset = toTimeOffset(element.getAttribute("timeOffset")); - if (timeOffset === null) { - return; - } - - const slot = getSlot(element); - if (!slot) { - return; - } - - let adBreak = adBreaks.find( - (adBreak) => adBreak.timeOffset === timeOffset, - ); - if (!adBreak) { - adBreak = { - timeOffset, - slots: [], - }; - adBreaks.push(adBreak); + const adBreaks = childList(rootElement).reduce( + (acc, element) => { + const adBreak = formatAdBreak(element); + if (adBreak) { + acc.push(adBreak); } - adBreak.slots.push(slot); - } - }); + return acc; + }, + [], + ); - return { adBreaks }; + return { + adBreaks, + }; } -function getAdSource(element: Element) { - return childList(element).find((child) => child.localName === "AdSource"); -} +function formatAdBreak(element: Element): VmapAdBreak | null { + if (element.localName !== "AdBreak") { + return null; + } + + const timeOffset = element.getAttribute("timeOffset"); + if (timeOffset === null) { + return null; + } -function getSlot(element: Element): VmapSlot { const vastUrl = getVastUrl(element); const vastData = getVastData(element); + if (!vastUrl && !vastData) { + return null; + } + return { + timeOffset, vastUrl, vastData, }; } +function getAdSource(element: Element) { + return childList(element).find((child) => child.localName === "AdSource"); +} + function getVastUrl(element: Element) { const adSource = getAdSource(element); if (!adSource) { @@ -105,15 +116,12 @@ function childList(node: Element) { return Array.from(node.childNodes) as Element[]; } -function toTimeOffset(value: string | null) { - if (value === null) { - return null; - } - if (value === "start") { +export function toAdBreakTimeOffset(adBreak: VmapAdBreak) { + if (adBreak.timeOffset === "start") { return 0; } - if (value === "end") { + if (adBreak.timeOffset === "end") { return null; } - return timeFormat.toS(value); + return toS(adBreak.timeOffset); } diff --git a/packages/stitcher/test/setup.ts b/packages/stitcher/test/setup.ts index f77a7560..b47f730b 100644 --- a/packages/stitcher/test/setup.ts +++ b/packages/stitcher/test/setup.ts @@ -5,6 +5,4 @@ process.env = { S3_ACCESS_KEY: "s3-access-key", S3_SECRET_KEY: "s3-secret-key", S3_BUCKET: "s3-bucket", - REDIS_HOST: "redis-host", - REDIS_PORT: "6379", }; diff --git a/packages/stitcher/wrangler.toml b/packages/stitcher/wrangler.toml index f0281c67..4792ff2e 100644 --- a/packages/stitcher/wrangler.toml +++ b/packages/stitcher/wrangler.toml @@ -1,14 +1,10 @@ -name = "superstreamer-stitcher" +name = "sprs-stitcher" compatibility_flags = [ "nodejs_compat" ] compatibility_date = "2024-09-23" send_metrics = false [vars] -SERVERLESS = "true" -PORT = "0" -HOST = "0.0.0.0" -REDIS_HOST = "redis" -REDIS_PORT = "0000" +KV = "cloudflare-kv" PUBLIC_S3_ENDPOINT = "https://cdn.superstreamer.xyz" PUBLIC_STITCHER_ENDPOINT = "https://stitcher.superstreamer.xyz" PUBLIC_API_ENDPOINT = "https://api.tunnel.superstreamer.xyz"